Author SHA1 Message Date
Hermes (Server)andClaude Opus 5 a44321c510 Hook: GIT_DIR laesst Flutter das falsche Repo als SDK lesen
Git reicht GIT_DIR, GIT_INDEX_FILE und GIT_WORK_TREE an Hooks weiter. Der
Hook ruft flutter auf, und Flutter ermittelt seine eigene Version per git in
seinem SDK-Verzeichnis - mit geerbtem GIT_DIR liest es stattdessen DIESES
Repo und erfindet daraus eine SDK-Version.

Nachgemessen (flutter --version, gleiche Maschine, nur GIT_DIR variiert):
  GIT_DIR=<repo>/.git/worktrees/<name>  ->  3.48.0-0.2.pre, channel
                                            [user-branch], revision =
                                            Commit dieses Branches
  GIT_DIR=<repo>/.git                   ->  3.48.0-0.2.pre, channel main,
                                            revision = Commit von main
  ohne GIT_DIR                          ->  3.44.8, channel stable

Es betrifft also BEIDE Faelle, nicht nur verlinkte Worktrees - nur das
sichtbare Symptom unterscheidet sich:
- Aus einem verlinkten Worktree scheitert die Abhaengigkeitsaufloesung
  ("The current Flutter SDK version is 0.0.0-unknown") und der Commit wird
  abgelehnt, obwohl mit dem Code alles in Ordnung ist.
- Aus dem Haupt-Checkout laeuft das Gate zwar durch, aber Flutter baut sein
  Werkzeug bei jedem Commit neu ("Building flutter tool...", ein bis zwei
  Minuten). Diesen Preis zahlt dort bisher jeder Commit unbemerkt.

Zusaetzlich gibt Flutter dabei die Remote-URL des gelesenen Repos aus. Da
der Zugriffstoken in dieser URL eingebettet ist, stand er so in jeder
Hook-Ausgabe. Der Fix schliesst auch diesen Weg. (Die Token-Ablage selbst
gehoert getrennt behandelt - Credential-Helper statt URL-Einbettung.)

Fix: Repo-Wurzel merken, dann die von git gesetzten Variablen loeschen,
bevor flutter laeuft.

Getestet: Hook mit gesetztem GIT_DIR und GIT_INDEX_FILE aus einem
verlinkten Worktree aufgerufen - analyze und test laufen beide durch,
Exit 0. Unabhaengig von der zweiten Session im Haupt-Checkout bestaetigt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbuWT267W8rFDsaavANuEn
2026-08-21 15:33:38 +02:00
6 changed files with 37 additions and 350 deletions
+17 -1
View File
@@ -8,9 +8,25 @@ export PATH="$HOME/development/flutter/bin:$PATH"
echo "🔍 Melo App Qualitäts-Gate gestartet..."
# Git reicht GIT_DIR/GIT_INDEX_FILE/GIT_WORK_TREE an Hooks weiter. Flutter
# ermittelt seine Version per git im SDK-Verzeichnis — erbt es dabei GIT_DIR,
# liest es stattdessen DIESES Repo und erfindet daraus eine SDK-Version.
# Betrifft BEIDE Arbeitsbäume, nur unterschiedlich sichtbar:
# verlinkter Worktree -> harter Abbruch ("SDK version is 0.0.0-unknown"),
# korrekte Commits werden abgelehnt
# Haupt-Checkout -> still: Flutter baut sein Werkzeug bei JEDEM Commit
# neu (ein bis zwei Minuten pro Commit)
# Gemessen: ohne GIT_DIR meldet Flutter 3.44.8/stable, mit GIT_DIR dagegen
# 3.48.0-0.2.pre und als Revision einen Commit AUS DIESEM REPO.
# Nebeneffekt: Flutter gibt dabei die Remote-URL aus — steht dort ein Token
# in der URL, landet es in der Hook-Ausgabe. Das unset schließt auch das.
ROOT="$(git rev-parse --show-toplevel)" || exit 1
unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_PREFIX GIT_COMMON_DIR
cd "$ROOT" || exit 1
# 1. Dart Analyse
echo -n " Prüfe: flutter analyze ... "
cd "$(git rev-parse --show-toplevel)" || exit 1
ANALYZE_OUTPUT=$($FLUTTER analyze 2>&1)
if echo "$ANALYZE_OUTPUT" | grep -q "No issues found"; then
echo "✅"
-32
View File
@@ -111,38 +111,6 @@ Lieder/Kategorie bleiben unverändert.
(Material 3: 48 dp, Theme über `context`) und die Token-Disziplin aus
**Hue** (Primitives → benannte Rollen).
-**Getestet:** 318 Tests grün (23 neue), `flutter analyze` ohne Befund.
### 🛡️ Fehlerberichte nur noch mit Zustimmung (Opt-in) (2026-08-21)
- **Neu: Schalter „Fehlerberichte senden"** in den Einstellungen unter
*Datenschutz*. Bisher lud `LoggerService` jeden Fehler unbedingt zu
`logs.baka-net.de` hoch — es gab **keine** Möglichkeit, das abzustellen.
- Aus heißt wirklich aus: Die Sperre sitzt an der einzigen Stelle, durch die
jeder Upload läuft. Lokales Protokollieren (`debugPrint`) bleibt unberührt,
aber nichts verlässt mehr das Gerät. Der Puffer wird dabei geleert, damit er
im Dauerbetrieb nicht unbegrenzt wächst.
- Die Entscheidung **überlebt den Neustart**: `AppSettings` speichert sie, und
`LoggerService` liest sie beim Start zusätzlich selbst — denn `main()`
startet den Logger, *bevor* die Einstellungen geladen sind, und schon dieses
Zeitfenster darf bei Widerspruch nichts senden.
- Hintergrund: Übertragung eines Befunds aus dem Security-Audit der alten
Melo-App. Dort gab es den Schalter zwar, er hatte aber keine Wirkung über
einen Neustart hinaus — die Anzeige stand auf „aus", gesendet wurde weiter.
Hier ist das strukturell ausgeschlossen und durch Tests festgehalten.
- **Der Standard ist AUS.** Ohne ausdrückliche Zustimmung verlässt kein
Fehlerbericht das Gerät — auch nicht beim allerersten Start, bevor die
Einstellungen jemals geöffnet wurden. Wer Berichte senden möchte, schaltet
den Regler bewusst an.
- Tests: 12 neue in `test/settings/diagnose_opt_out_test.dart` (**340 gesamt**,
vorher 328). Fünf tragende Stellen per Mutationsprobe geprüft — ohne Sperre,
ohne Durchreichen an den Logger, ohne Lesen beim Start und mit wieder auf
„an" gedrehtem Standard (je einmal im Logger und in den Einstellungen) fallen
jeweils genau die zuständigen Tests um.
- Für die Testbarkeit bekam `LoggerService` einen injizierbaren HTTP-Client
(gleiches Muster wie `BakaAuth`), damit sich belegen lässt, dass ohne
Zustimmung wirklich **kein** Request rausgeht.
> Auswirkung auf bestehende Installationen: Wer bisher (unfreiwillig) Berichte
> gesendet hat, sendet ab diesem Update keine mehr, bis er zustimmt. Das ist so
> gewollt — die Zustimmung wurde nie eingeholt.
### 📋 Backlog-Datei angelegt (2026-08-21)
- Neu: `BACKLOG.md` — bewusst zurückgestellte Punkte an einer Stelle, damit
+20 -76
View File
@@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart';
class LogEntry {
final String level; // DEBUG, INFO, WARNING, ERROR
@@ -33,39 +32,12 @@ class LogEntry {
/// Centralized Logging Service mit Auto-Upload zu Server
class LoggerService extends ChangeNotifier {
/// [client] nur für Tests: erlaubt zu prüfen, dass bei Widerspruch des
/// Nutzers wirklich KEIN Request das Gerät verlässt.
LoggerService({http.Client? client}) : _client = client ?? http.Client();
final http.Client _client;
static const String _logServerUrl = 'https://logs.baka-net.de/api/logs';
static const int _batchSize = 5;
/// Schlüssel der Diagnose-Entscheidung in den SharedPreferences.
/// Liegt hier und nicht in AppSettings, damit der Logger ihn ohne
/// Rückwärts-Abhängigkeit selbst lesen kann.
static const String diagnoseKey = 'diagnose_daten';
final List<LogEntry> _logs = [];
late final String _deviceModel;
bool _initialized = false;
// Opt-in: standardmäßig AUS. Ohne ausdrückliche Zustimmung des Nutzers
// verlässt kein Log das Gerät.
bool _sendeDiagnosedaten = false;
/// Steuert, ob Logs den Server erreichen. Opt-in: ohne ausdrückliche
/// Zustimmung bleibt der Versand aus. Lokales `debugPrint` läuft
/// unabhängig davon weiter — nur der Versand wird unterbunden.
/// [AppSettings] hält den Wert persistent und schiebt Änderungen hierher.
bool get sendeDiagnosedaten => _sendeDiagnosedaten;
set sendeDiagnosedaten(bool wert) {
if (_sendeDiagnosedaten == wert) return;
_sendeDiagnosedaten = wert;
// Bei Widerspruch nichts Aufgestautes zurückbehalten.
if (!wert) _logs.clear();
}
Future<void> init() async {
if (_initialized) return;
@@ -74,17 +46,6 @@ class LoggerService extends ChangeNotifier {
_deviceModel = Platform.isAndroid
? 'android'
: (Platform.isIOS ? 'ios' : Platform.operatingSystem);
// Die Einstellung wird hier direkt gelesen und nicht von AppSettings
// abgewartet: main() initialisiert den Logger VOR den Einstellungen, und
// schon dieses Zeitfenster darf nichts senden, wenn der Nutzer
// widersprochen hat.
try {
final prefs = await SharedPreferences.getInstance();
_sendeDiagnosedaten = prefs.getBool(diagnoseKey) ?? false;
} catch (_) {
// Nicht lesbar: beim bisherigen Verhalten bleiben.
}
_initialized = true;
debugPrint('✅ LoggerService initialized for $_deviceModel');
}
@@ -100,10 +61,8 @@ class LoggerService extends ChangeNotifier {
static String scrubText(String? roh) {
if (roh == null || roh.isEmpty) return roh ?? '';
var s = roh.replaceAllMapped(
RegExp(
r'\b(user|username|benutzer|pw|pass|passwort|password|token|secret|url|serverurl)=([^\s&]+)',
caseSensitive: false,
),
RegExp(r'\b(user|username|benutzer|pw|pass|passwort|password|token|secret|url|serverurl)=([^\s&]+)',
caseSensitive: false),
(m) => '${m.group(1)}=<redacted>',
);
s = s
@@ -128,22 +87,17 @@ class LoggerService extends ChangeNotifier {
/// bleibt unberührt — die Maskierung greift nur auf dem Versand-Pfad.
@visibleForTesting
static Map<String, dynamic> sanitizedJson(LogEntry l) => {
'level': l.level,
'message': scrubText(l.message),
'stackTrace': l.stackTrace == null
? null
: capFrames(scrubText(l.stackTrace)),
'timestamp': l.timestamp.toIso8601String(),
'userAgent': l.userAgent,
'deviceModel': l.deviceModel,
};
'level': l.level,
'message': scrubText(l.message),
'stackTrace':
l.stackTrace == null ? null : capFrames(scrubText(l.stackTrace)),
'timestamp': l.timestamp.toIso8601String(),
'userAgent': l.userAgent,
'deviceModel': l.deviceModel,
};
/// Logge einen Fehler (wird sofort zum Server geschickt)
Future<void> error(
String message, [
Object? error,
StackTrace? stackTrace,
]) async {
Future<void> error(String message, [Object? error, StackTrace? stackTrace]) async {
final entry = LogEntry(
level: 'ERROR',
message: message,
@@ -192,29 +146,19 @@ class LoggerService extends ChangeNotifier {
/// Hochladen der Logs zum Server
Future<void> _uploadLogs() async {
// Opt-out des Nutzers: nichts verlässt das Gerät. Der Puffer wird
// trotzdem geleert, sonst wüchse er im Dauerbetrieb unbegrenzt.
if (!_sendeDiagnosedaten) {
_logs.clear();
return;
}
if (_logs.isEmpty) return;
try {
final logsToUpload = List<LogEntry>.from(_logs);
await _client
.post(
Uri.parse(_logServerUrl),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'logs': logsToUpload.map(sanitizedJson).toList(),
'timestamp': DateTime.now().toIso8601String(),
}),
)
.timeout(const Duration(seconds: 10));
await http.post(
Uri.parse(_logServerUrl),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'logs': logsToUpload.map(sanitizedJson).toList(),
'timestamp': DateTime.now().toIso8601String(),
}),
).timeout(const Duration(seconds: 10));
_logs.clear();
debugPrint(
'✅ Logs uploaded successfully (${logsToUpload.length} entries)',
);
debugPrint('✅ Logs uploaded successfully (${logsToUpload.length} entries)');
} catch (e) {
debugPrint('⚠️ Failed to upload logs: $e');
}
-23
View File
@@ -1,21 +1,16 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/logger_service.dart';
/// Nutzer-Einstellungen, die die ganze App betreffen. Werden beim Start
/// einmal geladen und bei jeder Änderung sofort gespeichert.
class AppSettings extends ChangeNotifier {
static const _keyGroupCovers = 'gleiche_kategorie_gleiches_cover';
static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer';
static const _keyNormalizeVolume = 'gleiche_lautstaerke';
// Schlüssel gehört dem Logger — der liest ihn beim Start selbst.
static const _keyDiagnose = LoggerService.diagnoseKey;
bool _groupCoversByCategory = true;
bool _autoPlayOnHeadphones = false;
bool _normalizeVolume = true;
bool _sendeDiagnosedaten = false;
/// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der
/// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild.
@@ -28,20 +23,11 @@ class AppSettings extends ChangeNotifier {
/// auf das Niveau der leiseren abgesenkt.
bool get normalizeVolume => _normalizeVolume;
/// Fehler- und Absturzberichte an logs.baka-net.de senden.
/// Opt-in: standardmäßig AUS. Die App protokolliert weiter lokal, aber
/// ohne Zustimmung verlässt nichts das Gerät.
bool get sendeDiagnosedaten => _sendeDiagnosedaten;
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
_groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true;
_autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false;
_normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true;
_sendeDiagnosedaten = prefs.getBool(_keyDiagnose) ?? false;
// An den Logger durchreichen, damit Anzeige und tatsächliches
// Sendeverhalten nicht auseinanderlaufen können.
logger.sendeDiagnosedaten = _sendeDiagnosedaten;
notifyListeners();
}
@@ -68,13 +54,4 @@ class AppSettings extends ChangeNotifier {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyAutoPlayHeadphones, value);
}
Future<void> setSendeDiagnosedaten(bool value) async {
if (_sendeDiagnosedaten == value) return;
_sendeDiagnosedaten = value;
logger.sendeDiagnosedaten = value;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyDiagnose, value);
}
}
-12
View File
@@ -244,18 +244,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const Divider(height: 1),
const _SectionLabel('Datenschutz'),
Consumer<AppSettings>(
builder: (context, settings, _) => SwitchListTile(
secondary: const Icon(Icons.shield_outlined),
title: const Text('Fehlerberichte senden'),
subtitle: const Text(
'Standardmäßig aus. An: Absturzberichte helfen beim Beheben von Fehlern'),
value: settings.sendeDiagnosedaten,
onChanged: settings.setSendeDiagnosedaten,
),
),
const Divider(height: 1),
const _SectionLabel('Über Melo'),
const ListTile(
leading: Icon(Icons.info_outline),
-206
View File
@@ -1,206 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:http/testing.dart';
import 'package:http/http.dart' as http;
import 'package:melo/services/logger_service.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Übertragung eines Befunds aus dem Security-Audit der alten Melo-App
/// (21.08.2026): Dort stand die Diagnose-Einstellung in einer statischen
/// Variablen mit Default `true`, die beim Start nie aus den Einstellungen
/// gefüllt wurde — ein Opt-out überlebte keinen Neustart, während der
/// Schalter weiterhin "aus" anzeigte.
///
/// Hier wird festgehalten, dass genau das nicht passieren kann.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// Opt-in ist der Ruhezustand: nach jedem Test wieder aus.
tearDown(() => logger.sendeDiagnosedaten = false);
group('AppSettings.init', () {
test('übernimmt ein gespeichertes Opt-out und reicht es an den Logger', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: false});
logger.sendeDiagnosedaten = true; // Zustand nach frischem App-Start
final settings = AppSettings();
await settings.init();
expect(settings.sendeDiagnosedaten, isFalse);
expect(logger.sendeDiagnosedaten, isFalse,
reason: 'Anzeige und Sendeverhalten dürfen nicht auseinanderlaufen');
});
test('ohne gespeicherte Entscheidung bleibt der Versand AUS (Opt-in)', () async {
SharedPreferences.setMockInitialValues({});
logger.sendeDiagnosedaten = true; // absichtlich "falsch" vorbelegt
final settings = AppSettings();
await settings.init();
expect(settings.sendeDiagnosedaten, isFalse,
reason: 'Ohne ausdrueckliche Zustimmung darf nichts gesendet werden');
expect(logger.sendeDiagnosedaten, isFalse);
});
test('eine gespeicherte Zustimmung wird respektiert', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true});
final settings = AppSettings();
await settings.init();
expect(settings.sendeDiagnosedaten, isTrue);
expect(logger.sendeDiagnosedaten, isTrue);
});
test('eine erteilte Zustimmung überlebt den Neustart', () async {
SharedPreferences.setMockInitialValues({});
final ersteSitzung = AppSettings();
await ersteSitzung.init();
await ersteSitzung.setSendeDiagnosedaten(true);
// Neustart: neue Instanzen, gleiche Ablage.
logger.sendeDiagnosedaten = false;
final zweiteSitzung = AppSettings();
await zweiteSitzung.init();
expect(zweiteSitzung.sendeDiagnosedaten, isTrue);
expect(logger.sendeDiagnosedaten, isTrue);
});
test('ein Widerruf überlebt den Neustart', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true});
final ersteSitzung = AppSettings();
await ersteSitzung.init();
await ersteSitzung.setSendeDiagnosedaten(false);
logger.sendeDiagnosedaten = true;
final zweiteSitzung = AppSettings();
await zweiteSitzung.init();
expect(zweiteSitzung.sendeDiagnosedaten, isFalse);
expect(logger.sendeDiagnosedaten, isFalse);
});
});
group('AppSettings.setSendeDiagnosedaten', () {
test('schaltet den Logger sofort um und speichert', () async {
SharedPreferences.setMockInitialValues({});
final settings = AppSettings();
await settings.init();
// Ausgangspunkt ist jetzt AUS (Opt-in), also zuerst zustimmen.
expect(settings.sendeDiagnosedaten, isFalse);
await settings.setSendeDiagnosedaten(true);
expect(logger.sendeDiagnosedaten, isTrue);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool(LoggerService.diagnoseKey), isTrue);
await settings.setSendeDiagnosedaten(false);
expect(logger.sendeDiagnosedaten, isFalse);
expect(prefs.getBool(LoggerService.diagnoseKey), isFalse);
});
test('benachrichtigt Zuhörer', () async {
SharedPreferences.setMockInitialValues({});
final settings = AppSettings();
await settings.init();
var rufe = 0;
settings.addListener(() => rufe++);
await settings.setSendeDiagnosedaten(true);
await settings.setSendeDiagnosedaten(true); // unverändert → kein Ruf
expect(rufe, 1);
});
});
group('LoggerService Versand-Sperre', () {
/// Der Kern des Opt-outs: bei Widerspruch darf KEIN Request das Gerät
/// verlassen. Ohne diesen Test wäre genau die Sperre ungeprüft.
test('sendet nichts, wenn der Nutzer widersprochen hat', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: false});
var requests = 0;
final dienst = LoggerService(
client: MockClient((_) async {
requests++;
return http.Response('{}', 200);
}),
);
await dienst.init();
await dienst.error('Absturz mit Details');
await dienst.flush();
expect(requests, 0, reason: 'Bei Opt-out darf nichts hochgeladen werden');
expect(dienst.logCount, 0, reason: 'Puffer darf nicht unbegrenzt wachsen');
});
test('sendet, wenn der Nutzer zugestimmt hat', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true});
var requests = 0;
final dienst = LoggerService(
client: MockClient((_) async {
requests++;
return http.Response('{}', 200);
}),
);
await dienst.init();
await dienst.error('Absturz mit Details');
expect(requests, greaterThan(0));
});
test('das Umschalten auf aus stoppt den Versand sofort', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true});
var requests = 0;
final dienst = LoggerService(
client: MockClient((_) async {
requests++;
return http.Response('{}', 200);
}),
);
await dienst.init();
dienst.sendeDiagnosedaten = false;
await dienst.error('Absturz nach dem Abschalten');
await dienst.flush();
expect(requests, 0);
});
});
group('LoggerService.init', () {
test('liest das Opt-out selbst, ohne auf AppSettings zu warten', () async {
// main() initialisiert den Logger VOR den Einstellungen — auch dieses
// Zeitfenster darf nichts senden.
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: false});
final eigener = LoggerService();
await eigener.init();
expect(eigener.sendeDiagnosedaten, isFalse);
});
test('ein frischer Logger sendet ohne gespeicherte Zustimmung nicht', () async {
SharedPreferences.setMockInitialValues({});
var requests = 0;
final dienst = LoggerService(
client: MockClient((_) async {
requests++;
return http.Response('{}', 200);
}),
);
await dienst.init();
await dienst.error('Absturz beim allerersten Start');
await dienst.flush();
expect(dienst.sendeDiagnosedaten, isFalse);
expect(requests, 0,
reason: 'Erststart ohne Zustimmung darf nichts hochladen');
});
});
}