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 {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
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 {
namespace = "com.example.ocideck"
compileSdk = flutter.compileSdkVersion
@ -25,11 +39,27 @@ android {
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 {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// Use the real release keystore when configured; otherwise fall back
// to the debug key so `flutter run --release` still works locally.
// 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:shared_preferences/shared_preferences.dart';
const _consentKey = 'app_consent_accepted';
final consentProvider =
NotifierProvider<ConsentNotifier, ConsentState>(() {
final consentProvider = NotifierProvider<ConsentNotifier, ConsentState>(() {
return ConsentNotifier();
});
@ -12,15 +12,9 @@ class ConsentState {
final bool hasAccepted;
final bool isLoading;
const ConsentState({
required this.hasAccepted,
this.isLoading = false,
});
const ConsentState({required this.hasAccepted, this.isLoading = false});
ConsentState copyWith({
bool? hasAccepted,
bool? isLoading,
}) {
ConsentState copyWith({bool? hasAccepted, bool? isLoading}) {
return ConsentState(
hasAccepted: hasAccepted ?? this.hasAccepted,
isLoading: isLoading ?? this.isLoading,
@ -41,6 +35,8 @@ class ConsentNotifier extends Notifier<ConsentState> {
final hasAccepted = prefs.getBool(_consentKey) ?? false;
state = state.copyWith(hasAccepted: hasAccepted, isLoading: false);
} 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);
}
}
@ -51,6 +47,9 @@ class ConsentNotifier extends Notifier<ConsentState> {
await prefs.setBool(_consentKey, true);
state = state.copyWith(hasAccepted: true);
} 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);
}
}
@ -61,6 +60,7 @@ class ConsentNotifier extends Notifier<ConsentState> {
await prefs.setBool(_consentKey, false);
state = state.copyWith(hasAccepted: false);
} catch (e) {
debugPrint('ConsentNotifier: could not persist consent revocation: $e');
state = state.copyWith(hasAccepted: false);
}
}

View file

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

View file

@ -101,9 +101,26 @@ class TabsNotifier extends StateNotifier<TabsState> {
for (final sub in _subs.values) {
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();
}
/// 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() {
final id = _nextId++;
final recoveryId = _uuid.v4();
@ -163,7 +180,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
// Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen.
final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen;
if (replaceEmpty) {
_subs.remove(state.tabs.first.id)?.cancel();
_disposeTab(state.tabs.first);
state = state.copyWith(tabs: restored, selectedIndex: 0);
} else {
final tabs = [...state.tabs, ...restored];
@ -282,7 +299,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
}
final tab = state.tabs[index];
_recovery.discard(tab.recoveryId);
_subs.remove(tab.id)?.cancel();
_disposeTab(tab);
final newTabs = List<TabInfo>.from(state.tabs)..removeAt(index);
final newSelected = index >= newTabs.length ? newTabs.length - 1 : index;
state = state.copyWith(tabs: newTabs, selectedIndex: newSelected);

View file

@ -166,7 +166,9 @@ void main() {
expect(n.state.revision, greaterThan(revisionBefore));
});
test('clearAllChecklists is a single undoable step that restores the checks', () {
test(
'clearAllChecklists is a single undoable step that restores the checks',
() {
final n = _notifier()..newDeck('D');
final slide = Slide.create(SlideType.bullets).copyWith(
listStyle: ListStyle.checklist,
@ -189,14 +191,14 @@ void main() {
expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']);
// ...and bumps the revision again so the open editor reflects the restore.
expect(n.state.revision, greaterThan(revisionAfterClear));
});
},
);
test('clearAllChecklists is a no-op when nothing is checked', () {
final n = _notifier()..newDeck('D');
final slide = Slide.create(SlideType.bullets).copyWith(
listStyle: ListStyle.checklist,
bullets: ['[ ] Open'],
);
final slide = Slide.create(
SlideType.bullets,
).copyWith(listStyle: ListStyle.checklist, bullets: ['[ ] Open']);
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
expect(n.state.canUndo, isFalse);
@ -472,4 +474,31 @@ void main() {
n.undo(); // één stap terug herstelt de hele vervanging
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 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/settings.dart';
@ -62,4 +64,67 @@ void main() {
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'));
});
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 {
final service = MarpHtmlService(
loadAsset: _diskLoader,
@ -108,7 +127,10 @@ void main() {}
codeTextColor: '#33FF33',
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 code{color:#33FF33'));