What changed, and why it matters
This commit adds optional privacy hardening features to the Stack Wallet mobile app. Users can now enable a 'Cover in background' screen and, on Android, 'Disable screenshots.' These are defensive controls that make it harder for someone to capture the app's contents from the recent-apps switcher or via screenshots. There is no indication this commit fixes an active security flaw or introduces malicious behavior; it is a feature addition.
No urgent action required. Reviewers may want to audit the mobile_app_privacy package source for platform-specific behavior and confirm the overlay does not interfere with accessibility or secure input. Treat as a normal privacy feature commit.
Security signals we found
Adds defensive screen privacy overlay for app backgrounding
Adds Android FLAG_SECURE screenshot blocking toggle
Introduces new third-party dependency from vendor's own Git repository
No vulnerability fix, exploit, or unsafe code pattern visible in diff
Evidence from the diff
The change integrates the mobile_app_privacy package (v0.0.3 from a Cypher Stack Git repository) and wires it into the Flutter app lifecycle. On Android/iOS it can show an overlay when the app leaves the foreground (privacyScreen preference) and set FLAG_SECURE on Android to block screenshots (disableScreenShots preference). Two new persisted preferences are added in lib/utilities/prefs.dart, UI toggles are added in the security settings view, and the overlay/flag are applied in lib/main.dart on startup, lifecycle changes, and preference changes.
Changed components
lib/main.dartlib/pages/settings_views/global_settings_view/security_views/security_view.dartlib/utilities/prefs.dartpubspec.lockscripts/app_config/templates/pubspec.template.yamlInspect captured patch +312 / −133
diff --git a/lib/main.dart b/lib/main.dart
index 1daf66c..b0d58af 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -23,6 +23,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:keyboard_dismisser/keyboard_dismisser.dart';
import 'package:logger/logger.dart';
+import 'package:mobile_app_privacy/mobile_app_privacy.dart';
import 'package:path_provider/path_provider.dart';
import 'package:window_size/window_size.dart';
@@ -328,6 +329,10 @@ class _MaterialAppWithThemeState extends ConsumerState<MaterialAppWithTheme>
with WidgetsBindingObserver {
static const platform = MethodChannel("STACK_WALLET_RESTORE");
+ final _mobileAppPrivacy = Platform.isAndroid || Platform.isIOS
+ ? MobileAppPrivacy()
+ : null;
+
// late final Wallets _wallets;
// late final Prefs _prefs;
late final NotificationsService _notificationsService;
@@ -459,6 +464,11 @@ class _MaterialAppWithThemeState extends ConsumerState<MaterialAppWithTheme>
});
}
+ if (Platform.isAndroid &&
+ ref.read(prefsChangeNotifierProvider).disableScreenShots) {
+ unawaited(_mobileAppPrivacy?.setFlagSecure(true));
+ }
+
String themeId;
if (ref.read(prefsChangeNotifierProvider).enableSystemBrightness) {
final brightness = WidgetsBinding.instance.window.platformBrightness;
@@ -554,7 +564,18 @@ class _MaterialAppWithThemeState extends ConsumerState<MaterialAppWithTheme>
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
debugPrint("didChangeAppLifecycleState: ${state.name}");
- if (state == AppLifecycleState.resumed) {}
+
+ if (state == AppLifecycleState.resumed) {
+ await _mobileAppPrivacy?.disableOverlay();
+ } else {
+ if (ref.read(prefsChangeNotifierProvider).privacyScreen) {
+ await _mobileAppPrivacy?.enableOverlay(
+ color: ref.read(themeProvider).popupBG, // only android, ios uses blur
+ blurInsteadOfColor: true, // ignored on android
+ );
+ }
+ }
+
switch (state) {
case AppLifecycleState.inactive:
break;
@@ -692,6 +713,13 @@ class _MaterialAppWithThemeState extends ConsumerState<MaterialAppWithTheme>
// addToDebugMessagesDB: false);
// });
+ if (Platform.isAndroid) {
+ ref.listen(
+ prefsChangeNotifierProvider.select((s) => s.disableScreenShots),
+ (_, next) => _mobileAppPrivacy?.setFlagSecure(next),
+ );
+ }
+
final colorScheme = ref.watch(colorProvider.state).state;
return MaterialApp(
diff --git a/lib/pages/settings_views/global_settings_view/security_views/security_view.dart b/lib/pages/settings_views/global_settings_view/security_views/security_view.dart
index 76331b0..c3608fb 100644
--- a/lib/pages/settings_views/global_settings_view/security_views/security_view.dart
+++ b/lib/pages/settings_views/global_settings_view/security_views/security_view.dart
@@ -8,6 +8,8 @@
*
*/
+import 'dart:io';
+
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -61,54 +63,50 @@ class _SecurityViewState extends ConsumerState<SecurityView> {
Future<void> _createDuressPin() async {
final result = await showDialog<bool>(
context: context,
- builder:
- (context) => StackDialogBase(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
+ builder: (context) => StackDialogBase(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text("Enable duress PIN", style: STextStyles.pageTitleH2(context)),
+ const SizedBox(height: 8),
+ Row(
children: [
- Text(
- "Enable duress PIN",
- style: STextStyles.pageTitleH2(context),
+ Flexible(
+ child: Text(
+ "When unlocking the app with a duress PIN, only wallets"
+ " marked as visible in duress mode will be loaded and"
+ " shown. Be aware that providing a duress PIN instead"
+ " of your real PIN to law enforcement, border agents,"
+ " or other authorities may be considered deception and"
+ " could carry legal consequences depending on your"
+ " jurisdiction. Use with care and according to your"
+ " threat model.",
+ style: STextStyles.smallMed14(context),
+ ),
),
- const SizedBox(height: 8),
- Row(
- children: [
- Flexible(
- child: Text(
- "When unlocking the app with a duress PIN, only wallets"
- " marked as visible in duress mode will be loaded and"
- " shown. Be aware that providing a duress PIN instead"
- " of your real PIN to law enforcement, border agents,"
- " or other authorities may be considered deception and"
- " could carry legal consequences depending on your"
- " jurisdiction. Use with care and according to your"
- " threat model.",
- style: STextStyles.smallMed14(context),
- ),
- ),
- ],
+ ],
+ ),
+ const SizedBox(height: 20),
+ Row(
+ children: [
+ Expanded(
+ child: SecondaryButton(
+ label: "Cancel",
+ onPressed: () => Navigator.of(context).pop(false),
+ ),
),
- const SizedBox(height: 20),
- Row(
- children: [
- Expanded(
- child: SecondaryButton(
- label: "Cancel",
- onPressed: () => Navigator.of(context).pop(false),
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: PrimaryButton(
- label: "Ok",
- onPressed: () => Navigator.of(context).pop(true),
- ),
- ),
- ],
+ const SizedBox(width: 8),
+ Expanded(
+ child: PrimaryButton(
+ label: "Ok",
+ onPressed: () => Navigator.of(context).pop(true),
+ ),
),
],
),
- ),
+ ],
+ ),
+ ),
);
if (result == true && mounted) {
@@ -116,14 +114,13 @@ class _SecurityViewState extends ConsumerState<SecurityView> {
context,
RouteGenerator.getRoute(
shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute,
- builder:
- (_) => const LockscreenView(
- showBackButton: true,
- routeOnSuccess: CreateDuressPinView.routeName,
- biometricsCancelButtonString: "CANCEL",
- biometricsLocalizedReason: "Authenticate to create duress PIN",
- biometricsAuthenticationTitle: "Create duress PIN",
- ),
+ builder: (_) => const LockscreenView(
+ showBackButton: true,
+ routeOnSuccess: CreateDuressPinView.routeName,
+ biometricsCancelButtonString: "CANCEL",
+ biometricsLocalizedReason: "Authenticate to create duress PIN",
+ biometricsAuthenticationTitle: "Create duress PIN",
+ ),
settings: const RouteSettings(name: "/createDuressPinLockscreen"),
),
);
@@ -133,66 +130,62 @@ class _SecurityViewState extends ConsumerState<SecurityView> {
Future<void> _deleteDuressPin() async {
await showDialog<void>(
context: context,
- builder:
- (context) => StackDialogBase(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
+ builder: (context) => StackDialogBase(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text("Disable duress PIN", style: STextStyles.pageTitleH2(context)),
+ const SizedBox(height: 8),
+ Row(
children: [
- Text(
- "Disable duress PIN",
- style: STextStyles.pageTitleH2(context),
- ),
- const SizedBox(height: 8),
- Row(
- children: [
- Flexible(
- child: Text(
- "Your duress pin will be deleted. "
- "You will be asked to create a PIN when you enable this again. "
- "Are you sure you want to continue?",
+ Flexible(
+ child: Text(
+ "Your duress pin will be deleted. "
+ "You will be asked to create a PIN when you enable this again. "
+ "Are you sure you want to continue?",
- style: STextStyles.smallMed14(context),
- ),
- ),
- ],
+ style: STextStyles.smallMed14(context),
+ ),
),
- const SizedBox(height: 20),
- Row(
- children: [
- Expanded(
- child: SecondaryButton(
- label: "Cancel",
- onPressed: Navigator.of(context).pop,
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: PrimaryButton(
- label: "Ok",
- onPressed: () async {
- try {
- await ref
- .read(secureStoreProvider)
- .delete(key: kDuressPinKey);
- } catch (e, s) {
- Logging.instance.f(
- "dpin delete failed!!",
- error: e,
- stackTrace: s,
- );
- }
+ ],
+ ),
+ const SizedBox(height: 20),
+ Row(
+ children: [
+ Expanded(
+ child: SecondaryButton(
+ label: "Cancel",
+ onPressed: Navigator.of(context).pop,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: PrimaryButton(
+ label: "Ok",
+ onPressed: () async {
+ try {
+ await ref
+ .read(secureStoreProvider)
+ .delete(key: kDuressPinKey);
+ } catch (e, s) {
+ Logging.instance.f(
+ "dpin delete failed!!",
+ error: e,
+ stackTrace: s,
+ );
+ }
- if (context.mounted) {
- Navigator.of(context).pop();
- }
- },
- ),
- ),
- ],
+ if (context.mounted) {
+ Navigator.of(context).pop();
+ }
+ },
+ ),
),
],
),
- ),
+ ],
+ ),
+ ),
);
ref.read(prefsChangeNotifierProvider).hasDuressPin = false;
@@ -235,15 +228,14 @@ class _SecurityViewState extends ConsumerState<SecurityView> {
RouteGenerator.getRoute(
shouldUseMaterialRoute:
RouteGenerator.useMaterialPageRoute,
- builder:
- (_) => const LockscreenView(
- showBackButton: true,
- routeOnSuccess: ChangePinView.routeName,
- biometricsCancelButtonString: "CANCEL",
- biometricsLocalizedReason:
- "Authenticate to change PIN",
- biometricsAuthenticationTitle: "Change PIN",
- ),
+ builder: (_) => const LockscreenView(
+ showBackButton: true,
+ routeOnSuccess: ChangePinView.routeName,
+ biometricsCancelButtonString: "CANCEL",
+ biometricsLocalizedReason:
+ "Authenticate to change PIN",
+ biometricsAuthenticationTitle: "Change PIN",
+ ),
settings: const RouteSettings(
name: "/changepinlockscreen",
),
@@ -312,8 +304,9 @@ class _SecurityViewState extends ConsumerState<SecurityView> {
),
onValueChanged: (newValue) {
ref
- .read(prefsChangeNotifierProvider)
- .useBiometrics = newValue;
+ .read(prefsChangeNotifierProvider)
+ .useBiometrics =
+ newValue;
},
),
),
@@ -358,8 +351,9 @@ class _SecurityViewState extends ConsumerState<SecurityView> {
),
onValueChanged: (newValue) {
ref
- .read(prefsChangeNotifierProvider)
- .randomizePIN = newValue;
+ .read(prefsChangeNotifierProvider)
+ .randomizePIN =
+ newValue;
},
),
),
@@ -405,8 +399,9 @@ class _SecurityViewState extends ConsumerState<SecurityView> {
),
onValueChanged: (newValue) {
ref
- .read(prefsChangeNotifierProvider)
- .autoPin = newValue;
+ .read(prefsChangeNotifierProvider)
+ .autoPin =
+ newValue;
},
),
),
@@ -417,6 +412,100 @@ class _SecurityViewState extends ConsumerState<SecurityView> {
},
),
),
+ const SizedBox(height: 8),
+ RoundedWhiteContainer(
+ child: Consumer(
+ builder: (_, ref, __) {
+ return RawMaterialButton(
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ ),
+ onPressed: null,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ "Cover in background",
+ style: STextStyles.titleBold12(context),
+ textAlign: TextAlign.left,
+ ),
+ SizedBox(
+ height: 20,
+ width: 40,
+ child: DraggableSwitchButton(
+ isOn: ref.watch(
+ prefsChangeNotifierProvider.select(
+ (value) => value.privacyScreen,
+ ),
+ ),
+ onValueChanged: (newValue) {
+ ref
+ .read(prefsChangeNotifierProvider)
+ .privacyScreen =
+ newValue;
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ if (Platform.isAndroid) const SizedBox(height: 8),
+ if (Platform.isAndroid)
+ RoundedWhiteContainer(
+ child: Consumer(
+ builder: (_, ref, __) {
+ return RawMaterialButton(
+ materialTapTargetSize:
+ MaterialTapTargetSize.shrinkWrap,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ ),
+ onPressed: null,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ "Disable screenshots",
+ style: STextStyles.titleBold12(context),
+ textAlign: TextAlign.left,
+ ),
+ SizedBox(
+ height: 20,
+ width: 40,
+ child: DraggableSwitchButton(
+ isOn: ref.watch(
+ prefsChangeNotifierProvider.select(
+ (value) => value.disableScreenShots,
+ ),
+ ),
+ onValueChanged: (newValue) {
+ ref
+ .read(prefsChangeNotifierProvider)
+ .disableScreenShots =
+ newValue;
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
if (!ref.watch(pDuress)) const SizedBox(height: 8),
if (!ref.watch(pDuress))
RoundedWhiteContainer(
@@ -508,8 +597,9 @@ class _SecurityViewState extends ConsumerState<SecurityView> {
),
onChanged: (newValue) {
ref
- .read(prefsChangeNotifierProvider)
- .biometricsDuress = newValue;
+ .read(prefsChangeNotifierProvider)
+ .biometricsDuress =
+ newValue;
},
),
),
@@ -536,17 +626,15 @@ class _SecurityViewState extends ConsumerState<SecurityView> {
RouteGenerator.getRoute(
shouldUseMaterialRoute:
RouteGenerator.useMaterialPageRoute,
- builder:
- (_) => const LockscreenView(
- showBackButton: true,
- routeOnSuccess:
- AutoLockTimeoutSettingsView.routeName,
- biometricsCancelButtonString: "CANCEL",
- biometricsLocalizedReason:
- "Authenticate to change auto lock settings",
- biometricsAuthenticationTitle:
- "Auto lock settings",
- ),
+ builder: (_) => const LockscreenView(
+ showBackButton: true,
+ routeOnSuccess:
+ AutoLockTimeoutSettingsView.routeName,
+ biometricsCancelButtonString: "CANCEL",
+ biometricsLocalizedReason:
+ "Authenticate to change auto lock settings",
+ biometricsAuthenticationTitle: "Auto lock settings",
+ ),
settings: const RouteSettings(
name: "/autoLockTimeoutSettingsLockScreen",
),
diff --git a/lib/utilities/prefs.dart b/lib/utilities/prefs.dart
index 09b2bbd..2f057de 100644
--- a/lib/utilities/prefs.dart
+++ b/lib/utilities/prefs.dart
@@ -81,6 +81,8 @@ class Prefs extends ChangeNotifier {
_logsPath = await _getLogsPath();
_logLevel = await _getLogLevel();
_autoLockInfo = await _getAutoLockInfo();
+ _privacyScreen = await _getPrivacyScreen();
+ _disableScreenShots = await _getDisableScreenShots();
_initialized = true;
}
@@ -1383,4 +1385,52 @@ class Prefs extends ChangeNotifier {
return (enabled: map["enabled"] as bool, minutes: map["minutes"] as int);
}
+
+ // mobile screen privacy
+ bool _privacyScreen = false;
+ bool get privacyScreen => _privacyScreen;
+ set privacyScreen(bool privacyScreen) {
+ if (_privacyScreen != privacyScreen) {
+ DB.instance.put<dynamic>(
+ boxName: DB.boxNamePrefs,
+ key: "privacyScreen",
+ value: privacyScreen,
+ );
+ _privacyScreen = privacyScreen;
+ notifyListeners();
+ }
+ }
+
+ Future<bool> _getPrivacyScreen() async {
+ return await DB.instance.get<dynamic>(
+ boxName: DB.boxNamePrefs,
+ key: "privacyScreen",
+ )
+ as bool? ??
+ false;
+ }
+
+ // android screen shot protection
+ bool _disableScreenShots = false;
+ bool get disableScreenShots => _disableScreenShots;
+ set disableScreenShots(bool disableScreenShots) {
+ if (_disableScreenShots != disableScreenShots) {
+ DB.instance.put<dynamic>(
+ boxName: DB.boxNamePrefs,
+ key: "disableScreenShots",
+ value: disableScreenShots,
+ );
+ _disableScreenShots = disableScreenShots;
+ notifyListeners();
+ }
+ }
+
+ Future<bool> _getDisableScreenShots() async {
+ return await DB.instance.get<dynamic>(
+ boxName: DB.boxNamePrefs,
+ key: "disableScreenShots",
+ )
+ as bool? ??
+ false;
+ }
}
diff --git a/pubspec.lock b/pubspec.lock
index 7ed626d..9fd1e98 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1611,6 +1611,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.6"
+ mobile_app_privacy:
+ dependency: "direct main"
+ description:
+ path: "."
+ ref: "v0.0.3"
+ resolved-ref: a949b6e79aa2c97af9d339690067800a5c5eb89e
+ url: "https://github.com/cypherstack/mobile_app_privacy"
+ source: git
+ version: "0.0.3"
mockingjay:
dependency: "direct dev"
description:
@@ -2673,5 +2682,5 @@ packages:
source: hosted
version: "0.2.4"
sdks:
- dart: ">=3.9.0 <4.0.0"
+ dart: ">=3.9.2 <4.0.0"
flutter: ">=3.29.0 <4.0.0"
diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml
index 80a1a18..2878e1f 100644
--- a/scripts/app_config/templates/pubspec.template.yaml
+++ b/scripts/app_config/templates/pubspec.template.yaml
@@ -251,6 +251,10 @@ dependencies:
saf_stream: ^0.12.3
unorm_dart: ^0.2.0
qr_code_scanner_plus: ^2.0.14
+ mobile_app_privacy:
+ git:
+ url: https://github.com/cypherstack/mobile_app_privacy
+ ref: v0.0.3
dev_dependencies:
flutter_test:
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.