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
Expand Up @@ -10,7 +10,7 @@ import android.media.MediaMetadataRetriever
import android.media.MediaMuxer
import android.os.Build
import androidx.annotation.RequiresApi
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
Expand Down Expand Up @@ -77,12 +77,31 @@ internal object AndroidImageProcessor {
}
}

class MainActivity : FlutterActivity() {
class MainActivity : FlutterFragmentActivity() {
private var mediaUploadChannel: MethodChannel? = null
private var appPrivacyChannel: MethodChannel? = null

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)

appPrivacyChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
APP_PRIVACY_CHANNEL,
).also { channel ->
channel.setMethodCallHandler { call, result ->
if (call.method != SET_RECENTS_PROTECTION_METHOD) {
result.notImplemented()
return@setMethodCallHandler
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Keep normal screenshots and screen recording available;
// only prevent Android from snapshotting Buzz for Recents.
setRecentsScreenshotEnabled(call.arguments != true)
}
result.success(null)
}
}

mediaUploadChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
MEDIA_UPLOAD_CHANNEL,
Expand Down Expand Up @@ -336,6 +355,8 @@ class MainActivity : FlutterActivity() {
}

companion object {
private const val APP_PRIVACY_CHANNEL = "xyz.block.buzz/app_privacy"
private const val SET_RECENTS_PROTECTION_METHOD = "setRecentsProtection"
private const val MEDIA_UPLOAD_CHANNEL = "buzz/media_upload"
private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload"
private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg"
Expand Down
7 changes: 7 additions & 0 deletions mobile/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ PODS:
- FlutterMacOS
- image_picker_ios (0.0.1):
- Flutter
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
- mobile_scanner (7.0.0):
- Flutter
- FlutterMacOS
Expand Down Expand Up @@ -45,6 +48,7 @@ DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
Expand All @@ -71,6 +75,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/darwin"
open_filex:
Expand All @@ -97,6 +103,7 @@ SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
Expand Down
2 changes: 2 additions & 0 deletions mobile/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>Buzz uses Face ID to confirm sensitive identity transfers.</string>
<key>NSCameraUsageDescription</key>
<string>Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing.</string>
<key>NSPhotoLibraryUsageDescription</key>
Expand Down
235 changes: 231 additions & 4 deletions mobile/lib/app.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:app_badge_plus/app_badge_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

import 'package:hooks_riverpod/hooks_riverpod.dart';
Expand All @@ -20,6 +22,7 @@ import 'shared/deeplink/pending_deep_link_provider.dart';
import 'shared/emoji/emoji_burst.dart';
import 'shared/relay/relay.dart';
import 'shared/read_state/read_state_provider.dart';
import 'shared/security/sensitive_action_authorizer.dart';
import 'shared/theme/theme.dart';
import 'shared/widgets/buzz_loading_indicator.dart';

Expand Down Expand Up @@ -131,10 +134,19 @@ class App extends HookConsumerWidget {
loading: () => const _SplashScreen(),
error: (_, _) => const PairingPage(),
data: (state) => switch (state.status) {
AuthStatus.authenticated => DeepLinkDispatcher(
child: HomePage(
settingsPageBuilder: _buildSettingsPage,
hasUnreadInbox: hasUnreadInbox,
AuthStatus.authenticated => AppLockGate(
communityId: state.community?.id,
enabled:
state.community?.sensitiveActionPolicy ==
SensitiveActionPolicy.enabled,
child: KeyedSubtree(
key: ValueKey(state.community?.id),
child: DeepLinkDispatcher(
child: HomePage(
settingsPageBuilder: _buildSettingsPage,
hasUnreadInbox: hasUnreadInbox,
),
),
),
),
_ => const DeepLinkDispatcher(
Expand Down Expand Up @@ -165,3 +177,218 @@ class _SplashScreen extends StatelessWidget {
);
}
}

const appLockTimeout = Duration(minutes: 5);
const _appPrivacyChannel = MethodChannel('xyz.block.buzz/app_privacy');

class AppLockGate extends ConsumerStatefulWidget {
const AppLockGate({
super.key,
required this.enabled,
required this.child,
this.communityId,
});

final bool enabled;
final Widget child;
final String? communityId;

@override
ConsumerState<AppLockGate> createState() => _AppLockGateState();
}

class _AppLockGateState extends ConsumerState<AppLockGate>
with WidgetsBindingObserver {
bool _locked = true;
bool _authenticating = false;
DateTime? _backgroundedAt;

@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_setAndroidRecentsProtection(widget.enabled);
if (!widget.enabled) _locked = false;
WidgetsBinding.instance.addPostFrameCallback((_) => _unlockIfNeeded());
}

@override
void didUpdateWidget(AppLockGate oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.enabled != widget.enabled) {
_setAndroidRecentsProtection(widget.enabled);
}
if (!widget.enabled) {
setState(() => _locked = false);
} else if (!oldWidget.enabled ||
oldWidget.communityId != widget.communityId) {
setState(() => _locked = true);
WidgetsBinding.instance.addPostFrameCallback(
(_) => _unlockIfNeeded(forceFresh: true),
);
}
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!widget.enabled ||
_authenticating ||
ref.read(sensitiveActionAuthorizationSessionProvider).isAuthorizing) {
return;
}
switch (state) {
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
case AppLifecycleState.hidden:
case AppLifecycleState.detached:
_backgroundedAt ??= ref.read(appLockClockProvider)();
setState(() => _locked = true);
case AppLifecycleState.resumed:
final backgroundedAt = _backgroundedAt;
_backgroundedAt = null;
if (backgroundedAt != null) {
final timedOut =
ref.read(appLockClockProvider)().difference(backgroundedAt) >=
appLockTimeout;
_unlockIfNeeded(forceFresh: timedOut);
}
}
}

Future<void> _unlockIfNeeded({bool forceFresh = false}) async {
if (!mounted || !widget.enabled || !_locked || _authenticating) return;
final session = ref.read(sensitiveActionAuthorizationSessionProvider);
if (!forceFresh && session.wasAuthorizedWithin(appLockTimeout)) {
setState(() => _locked = false);
return;
}

setState(() => _authenticating = true);
final result = await session.authorize();
if (!mounted || !widget.enabled) return;
setState(() {
_authenticating = false;
_locked = result != DeviceAuthResult.success;
});
}

Future<void> _setAndroidRecentsProtection(bool enabled) async {
if (defaultTargetPlatform != TargetPlatform.android) return;
try {
await _appPrivacyChannel.invokeMethod<void>(
'setRecentsProtection',
enabled,
);
} on MissingPluginException {
// Widget tests and unsupported embedders do not register this channel.
}
}

@override
void dispose() {
_setAndroidRecentsProtection(false);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}

Future<void> _leaveCommunity() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Leave community?'),
content: const Text(
'This removes the community and its identity from this phone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Leave community'),
),
],
),
);
if (confirmed == true && mounted) {
await ref.read(authProvider.notifier).signOut();
}
}

@override
Widget build(BuildContext context) {
final locked = widget.enabled && _locked;
return Stack(
fit: StackFit.expand,
children: [
Offstage(offstage: locked, child: widget.child),
if (locked) _buildLockScreen(context),
],
);
}

Widget _buildLockScreen(BuildContext context) {
final authenticationName = sensitiveActionAuthenticationName(
Theme.of(context).platform,
);
return Scaffold(
key: const Key('app-lock-screen'),
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
fit: StackFit.expand,
children: [
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/buzz-icon.png',
key: const Key('app-lock-logo'),
width: 112,
semanticLabel: 'Buzz',
),
const SizedBox(height: 32),
FilledButton(
key: const Key('app-lock-unlock-button'),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
disabledBackgroundColor: Colors.white54,
disabledForegroundColor: Colors.black54,
),
onPressed: _authenticating ? null : _unlockIfNeeded,
child: Text(
_authenticating
? 'Authenticating…'
: 'Unlock with $authenticationName',
),
),
],
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextButton(
key: const Key('app-lock-leave-community'),
style: TextButton.styleFrom(
foregroundColor: Colors.white54,
textStyle: Theme.of(context).textTheme.bodySmall,
),
onPressed: _leaveCommunity,
child: const Text('Leave community'),
),
),
),
],
),
),
);
}
}
Loading
Loading