Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,27 +1,64 @@
package chat.fluffy.fluffychat

import android.content.Context
import android.os.Build
import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine

import android.content.Context
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
private var appSwitcherPrivacyEnabled = false
private var appSwitcherPrivacyForeground = true

override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
}


override fun provideFlutterEngine(context: Context): FlutterEngine? {
return provideEngine(this)
}

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
// do nothing, because the engine was been configured in provideEngine

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are packages on pub.dev which can do this so I don't have to maintain it

@aaravrav aaravrav Jun 3, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really. The closest ones either keep FLAG_SECURE enabled while protection is active, which blocks foreground screenshots, or still require MainActivity lifecycle integration for app switcher only behavior.

The Android 13+ part of this PR uses setRecentsScreenshotEnabled(false), which none of the packages expose. That API is the reason screenshots remain allowed while Recents is hidden on Android 13+, and is the idiomatic way to do so.

I could if you prefer,

  1. switch to a package even if that means foreground screenshots are blocked while enabled, or
  2. first propose/add setRecentsScreenshotEnabled support upstream to a package such as screen_protector?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krille-chan Let me know because if you prefer the 2nd option then it'll take time to upstream those changes in the flutter package as well

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You there?

MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
APP_SWITCHER_PRIVACY_CHANNEL
).setMethodCallHandler { call, result ->
when (call.method) {
"setState" -> {
appSwitcherPrivacyEnabled = call.argument<Boolean>("enabled") ?: false
appSwitcherPrivacyForeground = call.argument<Boolean>("foreground") ?: true
applyAppSwitcherPrivacy()
result.success(null)
}
else -> result.notImplemented()
}
}
}

private fun applyAppSwitcherPrivacy() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
setRecentsScreenshotEnabled(!appSwitcherPrivacyEnabled)
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
return
}

if (appSwitcherPrivacyEnabled && !appSwitcherPrivacyForeground) {
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}

companion object {
private const val APP_SWITCHER_PRIVACY_CHANNEL =
"chat.fluffy/app_switcher_privacy"

var engine: FlutterEngine? = null

fun provideEngine(context: Context): FlutterEngine {
val eng = engine ?: FlutterEngine(context, emptyArray(), true, false)
engine = eng
Expand Down
1 change: 1 addition & 0 deletions lib/config/setting_keys.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ enum AppSettings<T> {
sendOnEnter<bool>('chat.fluffy.send_on_enter', false),
displayNavigationRail<bool>('chat.fluffy.display_navigation_rail', false),
experimentalVoip<bool>('chat.fluffy.experimental_voip', false),
appSwitcherPrivacy<String>('chat.fluffy.app_switcher_privacy', 'always'),
shareKeysWith<String>('chat.fluffy.share_keys_with_2', 'all'),
noEncryptionWarningShown<bool>(
'chat.fluffy.no_encryption_warning_shown',
Expand Down
27 changes: 26 additions & 1 deletion lib/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,31 @@
"placeholders": {}
},
"appLockDescription": "Lock the app when not using with a pin code",
"appSwitcherPrivacy": "App switcher privacy",
"@appSwitcherPrivacy": {
"type": "String",
"placeholders": {}
},
"appSwitcherPrivacyDescription": "Hide app content in the app switcher and recents screen",
"@appSwitcherPrivacyDescription": {
"type": "String",
"placeholders": {}
},
"appSwitcherPrivacyOff": "Off",
"@appSwitcherPrivacyOff": {
"type": "String",
"placeholders": {}
},
"appSwitcherPrivacyAppLock": "When app lock is enabled",
"@appSwitcherPrivacyAppLock": {
"type": "String",
"placeholders": {}
},
"appSwitcherPrivacyAlways": "Always",
"@appSwitcherPrivacyAlways": {
"type": "String",
"placeholders": {}
},
"archive": "Archive",
"@archive": {
"type": "String",
Expand Down Expand Up @@ -2785,4 +2810,4 @@
}
},
"deviceIdentityKey": "Device identity key"
}
}
9 changes: 9 additions & 0 deletions lib/pages/settings_security/settings_security.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/utils/app_switcher_privacy.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
import 'package:fluffychat/widgets/app_lock.dart';
Expand Down Expand Up @@ -52,6 +53,14 @@ class SettingsSecurityController extends State<SettingsSecurity> {
}
}

Future<void> setAppSwitcherPrivacyMode(AppSwitcherPrivacyMode? mode) async {
if (mode == null) return;
await AppSettings.appSwitcherPrivacy.setAppSwitcherPrivacyMode(mode);
if (!mounted) return;
await AppLock.of(context).syncAppSwitcherPrivacy();
setState(() {});
}

Future<void> deleteAccountAction() async {
final l10n = L10n.of(context);
final matrix = Matrix.of(context);
Expand Down
53 changes: 53 additions & 0 deletions lib/pages/settings_security/settings_security_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/utils/app_switcher_privacy.dart';
import 'package:fluffychat/utils/beautify_string_extension.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
Expand All @@ -27,6 +28,8 @@ class SettingsSecurityView extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final client = Matrix.of(context).client;
final appSwitcherPrivacyMode =
AppSettings.appSwitcherPrivacy.appSwitcherPrivacyMode;
final publicMasterKey =
client.userDeviceKeys[client.userID]?.masterKey?.publicKey;

Expand Down Expand Up @@ -77,6 +80,42 @@ class SettingsSecurityView extends StatelessWidget {
subtitle: L10n.of(context).sendReadReceiptsDescription,
setting: AppSettings.sendPublicReadReceipts,
),
if (PlatformInfos.isMobile) ...[
ListTile(
title: Text(L10n.of(context).appSwitcherPrivacy),
subtitle: Text(
L10n.of(context).appSwitcherPrivacyDescription,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Material(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
color: theme.colorScheme.onInverseSurface,
child: DropdownButton<AppSwitcherPrivacyMode>(
isExpanded: true,
padding: const EdgeInsets.symmetric(horizontal: 8.0),
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
underline: const SizedBox.shrink(),
value: appSwitcherPrivacyMode,
items: [
for (final mode in AppSwitcherPrivacyMode.values)
DropdownMenuItem<AppSwitcherPrivacyMode>(
value: mode,
child: Text(
_appSwitcherPrivacyModeLabel(context, mode),
),
),
],
onChanged: controller.setAppSwitcherPrivacyMode,
),
),
),
],
ListTile(
trailing: const Icon(Icons.chevron_right_outlined),
title: Text(L10n.of(context).blockedUsers),
Expand Down Expand Up @@ -197,6 +236,20 @@ class SettingsSecurityView extends StatelessWidget {
),
);
}

String _appSwitcherPrivacyModeLabel(
BuildContext context,
AppSwitcherPrivacyMode mode,
) {
switch (mode) {
case AppSwitcherPrivacyMode.off:
return L10n.of(context).appSwitcherPrivacyOff;
case AppSwitcherPrivacyMode.appLock:
return L10n.of(context).appSwitcherPrivacyAppLock;
case AppSwitcherPrivacyMode.always:
return L10n.of(context).appSwitcherPrivacyAlways;
}
}
}

extension on ShareKeysWith {
Expand Down
61 changes: 61 additions & 0 deletions lib/utils/app_switcher_privacy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later

import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

enum AppSwitcherPrivacyMode {
off,
appLock,
always;

static AppSwitcherPrivacyMode fromSetting(String setting) {
for (final mode in AppSwitcherPrivacyMode.values) {
if (mode.name == setting) {
return mode;
}
}
return AppSwitcherPrivacyMode.always;
}
}

extension AppSwitcherPrivacySetting on AppSettings<String> {
AppSwitcherPrivacyMode get appSwitcherPrivacyMode =>
AppSwitcherPrivacyMode.fromSetting(value);

Future<void> setAppSwitcherPrivacyMode(AppSwitcherPrivacyMode mode) =>
setItem(mode.name);
}

class AppSwitcherPrivacy {
static const _channelName = 'chat.fluffy/app_switcher_privacy';

@visibleForTesting
static const channel = MethodChannel(_channelName);

@visibleForTesting
static bool? debugIsAndroid;

@visibleForTesting
static bool? debugIsMobile;

static bool get isAndroid => debugIsAndroid ?? PlatformInfos.isAndroid;

static bool get isMobile => debugIsMobile ?? PlatformInfos.isMobile;

static Future<void> setState({
required bool enabled,
required bool foreground,
}) async {
if (!isAndroid) return;

await channel.invokeMethod<void>('setState', {
'enabled': enabled,
'foreground': foreground,
});
}
}
Loading