fix: handle app upgrade crashes from incompatible data and foreground service receivers (#312)

* fix: handle app upgrade crashes from incompatible data and foreground service

When upgrading from an older version, multiple issues caused crashes:

1. The Rust whitenoise crate's secrets store format changed, causing
   "Secrets store error: Key not found" during initialization. Since
   the Rust singleton (tokio::sync::OnceCell) can't be reinitialized
   after partial failure, a version marker file now detects stale data
   and wipes the data directory before Rust init runs.

2. The flutter_foreground_task plugin stored foregroundServiceType
   0x201 (dataSync|shortService) in native SharedPreferences, but the
   manifest only declares dataSync (0x01). Android rejected
   startForeground() with IllegalArgumentException. A one-time cleanup
   in MainActivity.onCreate() clears these native prefs.

3. FlutterSecureStorage's internal migration from EncryptedSharedPreferences
   re-introduced the old active_account_pubkey after deleteAll(). Fixed by
   calling readAll() first to trigger the migration, then deleteAll().

4. The plugin's RebootReceiver and RestartReceiver caused cascade crashes
   on package update. Both are now disabled via manifest merge overrides.

* chore: remove stale url_launcher from generated plugin files and add build improvements

Remove url_launcher references from Linux/Windows plugin registrants
(dependency was already removed from pubspec.yaml). Add split APK,
AAB build targets and quiet spinner to OpenSSL build script.
This commit is contained in:
JeffG
2026-02-19 14:47:51 +01:00
committed by GitHub
parent 0e4641888f
commit c3b4a19098
11 changed files with 209 additions and 73 deletions
+2
View File
@@ -73,6 +73,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fix app crashes on upgrade from older versions by wiping incompatible data before Rust initialization [PR #312](https://github.com/marmot-protocol/whitenoise/pull/312)
- Disable foreground task plugin receivers to prevent crashes on package update [PR #312](https://github.com/marmot-protocol/whitenoise/pull/312)
- Adds internet permission in android manifest [PR #7](https://github.com/marmot-protocol/sloth/pull/7)
- Fixes logout not working after app reinstall [PR #31](https://github.com/marmot-protocol/sloth/pull/31)
- Fixes sign out exception and adds dedicated sign out screen with private key backup [PR #45](https://github.com/marmot-protocol/sloth/pull/45)
+13 -1
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
@@ -42,6 +43,17 @@
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- Disable the plugin's receivers to prevent crashes on package update
and service restart. The foreground service is managed in the app's
normal startup flow instead. -->
<receiver
android:name="com.pravera.flutter_foreground_task.service.RebootReceiver"
android:enabled="false"
tools:node="replace" />
<receiver
android:name="com.pravera.flutter_foreground_task.service.RestartReceiver"
android:enabled="false"
tools:node="replace" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -1,13 +1,44 @@
package org.parres.whitenoise
import android.content.Context
import android.os.Bundle
import io.crates.keyring.Keyring
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
companion object {
// TODO: Remove migration cleanup in the next release.
private const val MIGRATION_PREFS = "org.parres.whitenoise.migration"
private const val KEY_CLEANED_FGS_PREFS = "cleaned_fgs_prefs_v1"
private val FOREGROUND_TASK_PREFS = listOf(
"com.pravera.flutter_foreground_task.prefs.FOREGROUND_SERVICE_STATUS",
"com.pravera.flutter_foreground_task.prefs.FOREGROUND_SERVICE_TYPES",
"com.pravera.flutter_foreground_task.prefs.FOREGROUND_TASK_OPTIONS",
"com.pravera.flutter_foreground_task.prefs.NOTIFICATION_OPTIONS",
)
}
override fun onCreate(savedInstanceState: Bundle?) {
cleanForegroundTaskPrefs()
super.onCreate(savedInstanceState)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Keyring.initializeNdkContext(applicationContext)
flutterEngine.plugins.add(AndroidSignerPlugin())
}
private fun cleanForegroundTaskPrefs() {
val migrationPrefs = getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE)
if (migrationPrefs.getBoolean(KEY_CLEANED_FGS_PREFS, false)) return
for (prefsName in FOREGROUND_TASK_PREFS) {
getSharedPreferences(prefsName, Context.MODE_PRIVATE).edit().clear().commit()
}
migrationPrefs.edit().putBoolean(KEY_CLEANED_FGS_PREFS, true).commit()
}
}
+13
View File
@@ -258,12 +258,25 @@ build-android-quiet:
build-android-apk flavor:
./scripts/build_android.sh && flutter build apk --flavor {{flavor}}
# Build a fat APK (all ABIs in one file)
build-production-apk:
./scripts/build_android.sh && flutter build apk --flavor production
build-staging-apk:
./scripts/build_android.sh && flutter build apk --flavor staging
# Build per-ABI split APKs (separate .apk per architecture)
build-split-apk flavor="production":
./scripts/build_android.sh && flutter build apk --flavor {{flavor}} --split-per-abi
# Build an Android App Bundle (per-ABI splitting handled by Play Store)
build-aab flavor="production":
./scripts/build_android.sh && flutter build appbundle --flavor {{flavor}}
# Release builds
build-release-apk: (build-split-apk "production")
build-release-aab: (build-aab "production")
when-apk: build-staging-apk
# Run the app on a connected device (staging flavor by default)
+39 -16
View File
@@ -1,10 +1,11 @@
import 'dart:io' show Directory;
import 'dart:io' show Directory, File, FileSystemException;
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show DeviceOrientation, SystemChrome;
import 'package:flutter_foreground_task/flutter_foreground_task.dart' show FlutterForegroundTask;
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart' show ScreenUtilInit;
import 'package:flutter_secure_storage/flutter_secure_storage.dart' show FlutterSecureStorage;
import 'package:go_router/go_router.dart' show GoRouter;
import 'package:hooks_riverpod/hooks_riverpod.dart'
show ConsumerStatefulWidget, ConsumerState, ProviderContainer, UncontrolledProviderScope;
@@ -19,7 +20,9 @@ import 'package:whitenoise/src/rust/api.dart' as rust_api;
import 'package:whitenoise/src/rust/frb_generated.dart';
import 'package:whitenoise/theme.dart';
const kUnencryptedDatabaseError = 'database was created without encryption';
// TODO: Remove migration gate and related code in the next release.
const kDataVersion = 1;
const kDataVersionFile = 'data_version';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -38,26 +41,46 @@ Future<ProviderContainer> initializeAppContainer() async {
final logsDir = '${dir.path}/whitenoise/logs';
await Directory(dataDir).create(recursive: true);
await Directory(logsDir).create(recursive: true);
final config = await rust_api.createWhitenoiseConfig(dataDir: dataDir, logsDir: logsDir);
try {
await rust_api.initializeWhitenoise(config: config);
} catch (e) {
if (!e.toString().contains(kUnencryptedDatabaseError)) {
rethrow;
}
final envDir = Directory('$dataDir/${kDebugMode ? 'dev' : 'release'}');
if (envDir.existsSync()) {
await envDir.delete(recursive: true);
}
await rust_api.initializeWhitenoise(config: config);
}
await _migrateDataIfNeeded(dataDir);
final config = await rust_api.createWhitenoiseConfig(dataDir: dataDir, logsDir: logsDir);
await rust_api.initializeWhitenoise(config: config);
final container = ProviderContainer();
await container.read(authProvider.future);
return container;
}
Future<void> _migrateDataIfNeeded(String dataDir) async {
final versionFile = File('$dataDir/$kDataVersionFile');
int? currentVersion;
try {
if (versionFile.existsSync()) {
currentVersion = int.tryParse(versionFile.readAsStringSync().trim());
}
} on FileSystemException {
// Corrupt or unreadable file — treat as no version.
}
if (currentVersion == kDataVersion) return;
final dataDirObj = Directory(dataDir);
if (dataDirObj.existsSync()) {
await dataDirObj.delete(recursive: true);
await dataDirObj.create(recursive: true);
}
// Read triggers the internal migration from EncryptedSharedPreferences to
// the new cipher storage. Then deleteAll clears everything including any
// keys the migration re-introduced from the old app.
const secureStorage = FlutterSecureStorage();
await secureStorage.readAll();
await secureStorage.deleteAll();
await FlutterForegroundTask.clearAllData();
versionFile.writeAsStringSync('$kDataVersion');
}
class WnApp extends ConsumerStatefulWidget {
const WnApp({super.key});
@@ -9,7 +9,6 @@
#include <emoji_picker_flutter/emoji_picker_flutter_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) emoji_picker_flutter_registrar =
@@ -21,7 +20,4 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}
-1
View File
@@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
emoji_picker_flutter
file_selector_linux
flutter_secure_storage_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+52 -13
View File
@@ -17,6 +17,38 @@ print_error() {
echo -e "\033[1;31m$1\033[0m"
}
# Spinner for long-running commands
# Usage: run_quiet "description" logfile command [args...]
run_quiet() {
local desc="$1"
local logfile="$2"
shift 2
printf " %-40s " "$desc"
# Run command in background with output redirected to log file
"$@" >> "$logfile" 2>&1 &
local pid=$!
local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local i=0
while kill -0 "$pid" 2>/dev/null; do
printf "\b%s" "${spin:i++%${#spin}:1}"
sleep 0.1
done
wait "$pid"
local exit_code=$?
if [ $exit_code -eq 0 ]; then
printf "\b\033[1;32m✓\033[0m\n"
else
printf "\b\033[1;31m✗\033[0m\n"
print_error "Command failed. Last 20 lines of log:"
tail -20 "$logfile"
print_error "Full log: $logfile"
exit 1
fi
}
# Configuration
OPENSSL_VERSION="3.4.1"
OPENSSL_SHA256="002a2d6b30b58bf4bea46c43bdd96365aaf8daa6c428782aa4feee06da197df3"
@@ -86,33 +118,40 @@ build_openssl_for_target() {
# Clean and re-extract source for each target (OpenSSL doesn't support out-of-tree builds well)
local build_src="$OPENSSL_BUILD_DIR/build-$rust_target"
local logfile="$OPENSSL_BUILD_DIR/$rust_target.log"
rm -rf "$build_src"
: > "$logfile"
cp -r "$OPENSSL_SRC_DIR" "$build_src"
cd "$build_src"
export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME"
export PATH="$TOOLCHAIN/bin:$PATH"
./Configure "$openssl_target" \
-D__ANDROID_API__=$ANDROID_API \
--prefix="$install_prefix" \
--openssldir="$install_prefix/ssl" \
no-shared \
no-tests \
no-ui-console \
no-stdio \
-fPIC
run_quiet "Configuring ($openssl_target)..." "$logfile" \
./Configure "$openssl_target" \
-D__ANDROID_API__=$ANDROID_API \
--prefix="$install_prefix" \
--openssldir="$install_prefix/ssl" \
no-shared \
no-tests \
no-ui-console \
no-stdio \
-fPIC
make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" build_libs
make install_dev
run_quiet "Compiling..." "$logfile" \
make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" build_libs
run_quiet "Installing headers & libs..." "$logfile" \
make install_dev
cd "$PROJECT_ROOT"
rm -rf "$build_src"
if [ -f "$install_prefix/lib/libcrypto.a" ]; then
print_success "Built OpenSSL for $rust_target"
print_success " Built OpenSSL for $rust_target"
else
print_error "Failed to build OpenSSL for $rust_target"
print_error "Check log: $logfile"
exit 1
fi
}
@@ -125,7 +164,7 @@ if [ ! -d "$OPENSSL_SRC_DIR" ]; then
TARBALL="openssl-$OPENSSL_VERSION.tar.gz"
if [ ! -f "$TARBALL" ]; then
curl -LO "https://github.com/openssl/openssl/releases/download/openssl-$OPENSSL_VERSION/$TARBALL"
curl -L --progress-bar -o "$TARBALL" "https://github.com/openssl/openssl/releases/download/openssl-$OPENSSL_VERSION/$TARBALL"
fi
print_step "Verifying tarball integrity"
+59 -34
View File
@@ -5,7 +5,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'
show AsyncData, ProviderContainer, ProviderScope;
import 'package:flutter_test/flutter_test.dart';
import 'package:whitenoise/main.dart' show WnApp, initializeAppContainer, kUnencryptedDatabaseError;
import 'package:whitenoise/main.dart'
show WnApp, initializeAppContainer, kDataVersion, kDataVersionFile;
import 'package:whitenoise/providers/auth_provider.dart';
import 'package:whitenoise/providers/theme_provider.dart';
import 'package:whitenoise/src/rust/api.dart' as rust_api;
@@ -89,8 +90,6 @@ class _MockInitApi extends MockWnApi {
String? createdConfigLogsDir;
rust_api.WhitenoiseConfig? initializedConfig;
int initCallCount = 0;
int failFirstNCalls = 0;
String failMessage = kUnencryptedDatabaseError;
@override
Future<rust_api.WhitenoiseConfig> crateApiCreateWhitenoiseConfig({
@@ -107,9 +106,6 @@ class _MockInitApi extends MockWnApi {
required rust_api.WhitenoiseConfig config,
}) async {
initCallCount++;
if (initCallCount <= failFirstNCalls) {
throw Exception(failMessage);
}
initializedConfig = config;
}
@@ -120,8 +116,6 @@ class _MockInitApi extends MockWnApi {
createdConfigLogsDir = null;
initializedConfig = null;
initCallCount = 0;
failFirstNCalls = 0;
failMessage = kUnencryptedDatabaseError;
}
}
@@ -192,11 +186,19 @@ void main() {
setUp(() {
pathProvider = _mockPathProvider();
resetSecureStorage = _mockSecureStorage();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/shared_preferences'),
(call) async => <String, Object>{},
);
});
tearDown(() {
pathProvider.reset();
resetSecureStorage();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/shared_preferences'),
null,
);
});
test('creates data directory', () async {
@@ -241,38 +243,61 @@ void main() {
expect(container.read(authProvider), isA<AsyncData>());
});
test('wipes env data directory and retries on unencrypted db error', () async {
mockApi.failFirstNCalls = 1;
final envDir = Directory('${pathProvider.tempDir.path}/whitenoise/data/dev');
final marker = File('${envDir.path}/whitenoise.sqlite');
await envDir.create(recursive: true);
test('writes version file on fresh install', () async {
await initializeAppContainer();
final versionFile = File('${pathProvider.tempDir.path}/whitenoise/data/$kDataVersionFile');
expect(versionFile.existsSync(), isTrue);
expect(versionFile.readAsStringSync().trim(), '$kDataVersion');
});
test('skips migration when version matches', () async {
final dataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await dataDir.create(recursive: true);
final versionFile = File('${dataDir.path}/$kDataVersionFile');
versionFile.writeAsStringSync('$kDataVersion');
final marker = File('${dataDir.path}/whitenoise.json');
await marker.create();
await initializeAppContainer();
expect(marker.existsSync(), isTrue);
});
final container = await initializeAppContainer();
test('wipes data directory when no version file exists', () async {
final dataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await dataDir.create(recursive: true);
final oldSecrets = File('${dataDir.path}/whitenoise.json');
final oldUuid = File('${dataDir.path}/whitenoise_uuid');
final oldDb = File('${dataDir.path}/release/whitenoise.sqlite');
await Directory('${dataDir.path}/release').create(recursive: true);
await oldSecrets.create();
await oldUuid.create();
await oldDb.create();
await initializeAppContainer();
expect(oldSecrets.existsSync(), isFalse);
expect(oldUuid.existsSync(), isFalse);
expect(oldDb.existsSync(), isFalse);
expect(dataDir.existsSync(), isTrue);
final versionFile = File('${dataDir.path}/$kDataVersionFile');
expect(versionFile.existsSync(), isTrue);
expect(versionFile.readAsStringSync().trim(), '$kDataVersion');
});
test('wipes data directory when version is outdated', () async {
final dataDir = Directory('${pathProvider.tempDir.path}/whitenoise/data');
await dataDir.create(recursive: true);
final versionFile = File('${dataDir.path}/$kDataVersionFile');
versionFile.writeAsStringSync('0');
final marker = File('${dataDir.path}/whitenoise.json');
await marker.create();
await initializeAppContainer();
expect(container, isA<ProviderContainer>());
expect(marker.existsSync(), isFalse);
expect(mockApi.initCallCount, 2);
});
test('rethrows unencrypted db error when retry also fails', () async {
mockApi.failFirstNCalls = 2;
await expectLater(initializeAppContainer(), throwsException);
});
test('rethrows unrelated errors without wiping', () async {
mockApi.failFirstNCalls = 1;
mockApi.failMessage = 'network timeout';
final envDir = Directory('${pathProvider.tempDir.path}/whitenoise/data/dev');
final marker = File('${envDir.path}/whitenoise.sqlite');
await envDir.create(recursive: true);
await marker.create();
await expectLater(initializeAppContainer(), throwsException);
expect(marker.existsSync(), isTrue);
expect(versionFile.readAsStringSync().trim(), '$kDataVersion');
});
});
}
@@ -10,7 +10,6 @@
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
EmojiPickerFlutterPluginCApiRegisterWithRegistrar(
@@ -21,6 +20,4 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
-1
View File
@@ -7,7 +7,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
flutter_secure_storage_windows
permission_handler_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST