Bundle pre-existing in-progress changes

In-progress local work that predated this branch, committed alongside it:
localization updates (app_localizations.dart), consent/deck/tabs providers,
the Android Gradle build config, and their accompanying tests. Grouped here so
the structural changes on this branch stay separable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-11 22:16:57 +02:00
parent 6b2ba4df89
commit 97b825f1b9
8 changed files with 1127 additions and 919 deletions

View file

@ -1,9 +1,23 @@
import java.util.Properties
import java.io.FileInputStream
plugins { plugins {
id("com.android.application") id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
// Release signing is read from android/key.properties (kept out of version
// control). When it is absent we fall back to the debug key so that
// `flutter run --release` keeps working during development — but a build meant
// for distribution must provide a real keystore here.
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseKeystore = keystorePropertiesFile.exists()
if (hasReleaseKeystore) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android { android {
namespace = "com.example.ocideck" namespace = "com.example.ocideck"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@ -25,11 +39,27 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // Use the real release keystore when configured; otherwise fall back
// Signing with the debug keys for now, so `flutter run --release` works. // to the debug key so `flutter run --release` still works locally.
signingConfig = signingConfigs.getByName("debug") // Do NOT distribute a build signed with the debug key.
signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
} }
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
const _consentKey = 'app_consent_accepted'; const _consentKey = 'app_consent_accepted';
final consentProvider = final consentProvider = NotifierProvider<ConsentNotifier, ConsentState>(() {
NotifierProvider<ConsentNotifier, ConsentState>(() {
return ConsentNotifier(); return ConsentNotifier();
}); });
@ -12,15 +12,9 @@ class ConsentState {
final bool hasAccepted; final bool hasAccepted;
final bool isLoading; final bool isLoading;
const ConsentState({ const ConsentState({required this.hasAccepted, this.isLoading = false});
required this.hasAccepted,
this.isLoading = false,
});
ConsentState copyWith({ ConsentState copyWith({bool? hasAccepted, bool? isLoading}) {
bool? hasAccepted,
bool? isLoading,
}) {
return ConsentState( return ConsentState(
hasAccepted: hasAccepted ?? this.hasAccepted, hasAccepted: hasAccepted ?? this.hasAccepted,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
@ -41,6 +35,8 @@ class ConsentNotifier extends Notifier<ConsentState> {
final hasAccepted = prefs.getBool(_consentKey) ?? false; final hasAccepted = prefs.getBool(_consentKey) ?? false;
state = state.copyWith(hasAccepted: hasAccepted, isLoading: false); state = state.copyWith(hasAccepted: hasAccepted, isLoading: false);
} catch (e) { } catch (e) {
// Can't read the flag: fail closed (gate stays up) but don't hang loading.
debugPrint('ConsentNotifier: could not read consent flag: $e');
state = state.copyWith(isLoading: false); state = state.copyWith(isLoading: false);
} }
} }
@ -51,6 +47,9 @@ class ConsentNotifier extends Notifier<ConsentState> {
await prefs.setBool(_consentKey, true); await prefs.setBool(_consentKey, true);
state = state.copyWith(hasAccepted: true); state = state.copyWith(hasAccepted: true);
} catch (e) { } catch (e) {
// Persisting failed; let the user through this session, but the gate will
// reappear next launch. Surface the failure instead of swallowing it.
debugPrint('ConsentNotifier: could not persist consent: $e');
state = state.copyWith(hasAccepted: true); state = state.copyWith(hasAccepted: true);
} }
} }
@ -61,6 +60,7 @@ class ConsentNotifier extends Notifier<ConsentState> {
await prefs.setBool(_consentKey, false); await prefs.setBool(_consentKey, false);
state = state.copyWith(hasAccepted: false); state = state.copyWith(hasAccepted: false);
} catch (e) { } catch (e) {
debugPrint('ConsentNotifier: could not persist consent revocation: $e');
state = state.copyWith(hasAccepted: false); state = state.copyWith(hasAccepted: false);
} }
} }

View file

@ -201,7 +201,12 @@ class DeckNotifier extends StateNotifier<DeckState> {
void removeSlide(int index) { void removeSlide(int index) {
final deck = state.deck; final deck = state.deck;
if (deck == null || deck.slides.length <= 1) return; if (deck == null ||
deck.slides.length <= 1 ||
index < 0 ||
index >= deck.slides.length) {
return;
}
final slides = List<Slide>.from(deck.slides)..removeAt(index); final slides = List<Slide>.from(deck.slides)..removeAt(index);
_mutate(deck.copyWith(slides: slides)); _mutate(deck.copyWith(slides: slides));
} }
@ -254,7 +259,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
void duplicateSlide(int index) { void duplicateSlide(int index) {
final deck = state.deck; final deck = state.deck;
if (deck == null) return; if (deck == null || index < 0 || index >= deck.slides.length) return;
final slides = List<Slide>.from(deck.slides); final slides = List<Slide>.from(deck.slides);
slides.insert(index + 1, Slide.duplicate(slides[index])); slides.insert(index + 1, Slide.duplicate(slides[index]));
_mutate(deck.copyWith(slides: slides)); _mutate(deck.copyWith(slides: slides));
@ -272,7 +277,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
void updateSlide(int index, Slide updated) { void updateSlide(int index, Slide updated) {
final deck = state.deck; final deck = state.deck;
if (deck == null) return; if (deck == null || index < 0 || index >= deck.slides.length) return;
final slides = List<Slide>.from(deck.slides); final slides = List<Slide>.from(deck.slides);
slides[index] = updated; slides[index] = updated;
// Snel typen op dezelfde slide telt als één ongedaan-maken-stap. // Snel typen op dezelfde slide telt als één ongedaan-maken-stap.
@ -393,6 +398,9 @@ class DeckNotifier extends StateNotifier<DeckState> {
title: sub(s.title), title: sub(s.title),
subtitle: sub(s.subtitle), subtitle: sub(s.subtitle),
bullets: [for (final b in s.bullets) sub(b)], bullets: [for (final b in s.bullets) sub(b)],
bullets2: [for (final b in s.bullets2) sub(b)],
columnTitle1: sub(s.columnTitle1),
columnTitle2: sub(s.columnTitle2),
quote: sub(s.quote), quote: sub(s.quote),
quoteAuthor: sub(s.quoteAuthor), quoteAuthor: sub(s.quoteAuthor),
customMarkdown: sub(s.customMarkdown), customMarkdown: sub(s.customMarkdown),
@ -414,6 +422,9 @@ class DeckNotifier extends StateNotifier<DeckState> {
s.title, s.title,
s.subtitle, s.subtitle,
...s.bullets, ...s.bullets,
...s.bullets2,
s.columnTitle1,
s.columnTitle2,
s.quote, s.quote,
s.quoteAuthor, s.quoteAuthor,
s.customMarkdown, s.customMarkdown,

View file

@ -101,9 +101,26 @@ class TabsNotifier extends StateNotifier<TabsState> {
for (final sub in _subs.values) { for (final sub in _subs.values) {
sub.cancel(); sub.cancel();
} }
// The tabs' notifiers are not disposed here: at teardown the widget tree is
// still unmounting and may read a tab one last time. The process is ending
// anyway. The per-close path (_disposeTab) is what prevents the real leak.
super.dispose(); super.dispose();
} }
/// Tear down a tab that is being removed: stop listening to it and dispose
/// its notifiers so their listeners and undo/redo history are released. The
/// dispose is deferred to a microtask so any widget still referencing this
/// tab while it unmounts has finished before the notifiers go away.
void _disposeTab(TabInfo tab) {
_subs.remove(tab.id)?.cancel();
final deckNotifier = tab.deckNotifier;
final editorNotifier = tab.editorNotifier;
Future.microtask(() {
deckNotifier.dispose();
editorNotifier.dispose();
});
}
TabInfo _createTab() { TabInfo _createTab() {
final id = _nextId++; final id = _nextId++;
final recoveryId = _uuid.v4(); final recoveryId = _uuid.v4();
@ -163,7 +180,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
// Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen. // Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen.
final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen; final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen;
if (replaceEmpty) { if (replaceEmpty) {
_subs.remove(state.tabs.first.id)?.cancel(); _disposeTab(state.tabs.first);
state = state.copyWith(tabs: restored, selectedIndex: 0); state = state.copyWith(tabs: restored, selectedIndex: 0);
} else { } else {
final tabs = [...state.tabs, ...restored]; final tabs = [...state.tabs, ...restored];
@ -282,7 +299,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
} }
final tab = state.tabs[index]; final tab = state.tabs[index];
_recovery.discard(tab.recoveryId); _recovery.discard(tab.recoveryId);
_subs.remove(tab.id)?.cancel(); _disposeTab(tab);
final newTabs = List<TabInfo>.from(state.tabs)..removeAt(index); final newTabs = List<TabInfo>.from(state.tabs)..removeAt(index);
final newSelected = index >= newTabs.length ? newTabs.length - 1 : index; final newSelected = index >= newTabs.length ? newTabs.length - 1 : index;
state = state.copyWith(tabs: newTabs, selectedIndex: newSelected); state = state.copyWith(tabs: newTabs, selectedIndex: newSelected);

View file

@ -166,37 +166,39 @@ void main() {
expect(n.state.revision, greaterThan(revisionBefore)); expect(n.state.revision, greaterThan(revisionBefore));
}); });
test('clearAllChecklists is a single undoable step that restores the checks', () { test(
final n = _notifier()..newDeck('D'); 'clearAllChecklists is a single undoable step that restores the checks',
final slide = Slide.create(SlideType.bullets).copyWith( () {
listStyle: ListStyle.checklist, final n = _notifier()..newDeck('D');
bullets: ['[x] Klaar', '[ ] Open'], final slide = Slide.create(SlideType.bullets).copyWith(
bullets2: ['[x] Tweede'], listStyle: ListStyle.checklist,
); bullets: ['[x] Klaar', '[ ] Open'],
n.loadDeck(n.state.deck!.copyWith(slides: [slide])); bullets2: ['[x] Tweede'],
expect(n.checkedChecklistCount, 2); );
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
expect(n.checkedChecklistCount, 2);
n.clearAllChecklists(); n.clearAllChecklists();
expect(n.checkedChecklistCount, 0); expect(n.checkedChecklistCount, 0);
expect(n.state.canUndo, isTrue); expect(n.state.canUndo, isTrue);
final revisionAfterClear = n.state.revision; final revisionAfterClear = n.state.revision;
n.undo(); n.undo();
// One undo restores every checked item in both columns... // One undo restores every checked item in both columns...
expect(n.checkedChecklistCount, 2); expect(n.checkedChecklistCount, 2);
expect(n.state.deck!.slides.first.bullets, ['[x] Klaar', '[ ] Open']); expect(n.state.deck!.slides.first.bullets, ['[x] Klaar', '[ ] Open']);
expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']); expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']);
// ...and bumps the revision again so the open editor reflects the restore. // ...and bumps the revision again so the open editor reflects the restore.
expect(n.state.revision, greaterThan(revisionAfterClear)); expect(n.state.revision, greaterThan(revisionAfterClear));
}); },
);
test('clearAllChecklists is a no-op when nothing is checked', () { test('clearAllChecklists is a no-op when nothing is checked', () {
final n = _notifier()..newDeck('D'); final n = _notifier()..newDeck('D');
final slide = Slide.create(SlideType.bullets).copyWith( final slide = Slide.create(
listStyle: ListStyle.checklist, SlideType.bullets,
bullets: ['[ ] Open'], ).copyWith(listStyle: ListStyle.checklist, bullets: ['[ ] Open']);
);
n.loadDeck(n.state.deck!.copyWith(slides: [slide])); n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
expect(n.state.canUndo, isFalse); expect(n.state.canUndo, isFalse);
@ -472,4 +474,31 @@ void main() {
n.undo(); // één stap terug herstelt de hele vervanging n.undo(); // één stap terug herstelt de hele vervanging
expect(n.state.deck!.slides.first.title, 'Hallo wereld'); expect(n.state.deck!.slides.first.title, 'Hallo wereld');
}); });
test('find and replace covers the second column and both column titles', () {
final n = _notifier();
n.loadDeck(
Deck(
title: 'D',
slides: [
Slide.create(SlideType.twoBullets).copyWith(
title: 'foo title',
columnTitle1: 'foo left',
columnTitle2: 'foo right',
bullets: ['foo a'],
bullets2: ['foo b', 'foo c'],
),
],
),
);
// title + columnTitle1 + columnTitle2 + bullets(1) + bullets2(2) = 6
expect(n.countMatches('foo'), 6);
expect(n.replaceAll('foo', 'bar'), 6);
final s = n.state.deck!.slides.first;
expect(s.columnTitle1, 'bar left');
expect(s.columnTitle2, 'bar right');
expect(s.bullets2, ['bar b', 'bar c']);
});
} }

View file

@ -1,5 +1,7 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/deck.dart'; import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/settings.dart'; import 'package:ocideck/models/settings.dart';
@ -62,4 +64,67 @@ void main() {
expect(service.currentThemeProfile.logoPath, logo.path); expect(service.currentThemeProfile.logoPath, logo.path);
}, },
); );
test(
'importPackageBytes ignores path-traversal entries (zip slip)',
() async {
final temp = await Directory.systemTemp.createTemp('ocideck_zipslip_');
addTearDown(() async {
if (await temp.exists()) await temp.delete(recursive: true);
});
final archive = Archive();
final md = utf8.encode('---\nmarp: true\n---\n# Hi');
archive.addFile(ArchiveFile('deck.md', md.length, md));
final evil = utf8.encode('pwned');
archive.addFile(ArchiveFile('../evil.txt', evil.length, evil));
final zipBytes = ZipEncoder().encode(archive);
final service = FileService(
MarkdownService(),
ImageService(),
() => const ThemeProfile(),
);
final mdPath = await service.importPackageBytes(zipBytes, temp.path);
// The traversal entry must not have escaped the extraction folder.
expect(await File(p.join(temp.path, 'evil.txt')).exists(), isFalse);
// The legitimate markdown landed inside it.
expect(mdPath, isNotNull);
expect(p.isWithin(temp.path, mdPath!), isTrue);
expect(await File(mdPath).exists(), isTrue);
},
);
test(
'importFromUrl refuses non-web schemes and private/loopback hosts',
() async {
final temp = await Directory.systemTemp.createTemp('ocideck_ssrf_');
addTearDown(() async {
if (await temp.exists()) await temp.delete(recursive: true);
});
final service = FileService(
MarkdownService(),
ImageService(),
() => const ThemeProfile(),
);
// These are all rejected before any network access happens.
for (final url in [
'ftp://example.com/x', // non-web scheme
'file:///etc/passwd', // non-web scheme
'http://localhost:8080/x.ocideck', // loopback name
'http://127.0.0.1/x', // loopback IP
'http://192.168.1.5/x', // private IP
'http://10.0.0.9/x', // private IP
'http://169.254.1.1/x', // link-local IP
]) {
expect(
await service.importFromUrl(url, temp.path),
isNull,
reason: 'should refuse $url',
);
}
},
);
} }

View file

@ -77,6 +77,25 @@ void main() {}
expect(html, contains(r'<\/script')); expect(html, contains(r'<\/script'));
}); });
test('build() neutralises a mixed-case closing-script breakout', () async {
final service = MarpHtmlService(loadAsset: _diskLoader);
final html = await service.build('# X\n\nfoo </ScRiPt> bar');
// Case tricks must not slip past the guard.
expect(html, isNot(contains('</ScRiPt>')));
expect(html, contains(r'<\/ScRiPt'));
});
test(
'build() bundles DOMPurify and sanitises the rendered markdown',
() async {
final service = MarpHtmlService(loadAsset: _diskLoader);
final html = await service.build('# X');
// The sanitiser is inlined and actually used before content hits the DOM.
expect(html, contains('DOMPurify'));
expect(html, contains('DOMPurify.sanitize('));
},
);
test('a theme colours the slides with the profile palette', () async { test('a theme colours the slides with the profile palette', () async {
final service = MarpHtmlService( final service = MarpHtmlService(
loadAsset: _diskLoader, loadAsset: _diskLoader,
@ -108,7 +127,10 @@ void main() {}
codeTextColor: '#33FF33', codeTextColor: '#33FF33',
codeFontFamily: 'Courier New', codeFontFamily: 'Courier New',
); );
final html = await service.build('```dart\nvoid main() {}\n```', theme: theme); final html = await service.build(
'```dart\nvoid main() {}\n```',
theme: theme,
);
expect(html, contains('.slide pre{background:#000000;color:#33FF33')); expect(html, contains('.slide pre{background:#000000;color:#33FF33'));
expect(html, contains('.slide pre code{color:#33FF33')); expect(html, contains('.slide pre code{color:#33FF33'));