Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,6 @@ app.*.map.json
/secrets/
/android/build/reports/problems/problems-report.html
/android/.kotlin/

# Firebase config (kept out of the repo)
/android/app/google-services.json
2 changes: 2 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ plugins {
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
}

val secretsPropertiesFile = rootProject.file("../secrets/gradle.properties")
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />

<application
android:label="@string/app_name"
Expand Down
2 changes: 2 additions & 0 deletions android/settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
id("com.google.firebase.crashlytics") version "3.0.3" apply false
}

include(":app")
20 changes: 13 additions & 7 deletions lib/app/app.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:androidircx/app/theme/app_theme.dart';
import 'dart:async';

import 'package:androidircx/core/firebase/firebase_service.dart';
import 'package:androidircx/core/platform/foreground_connection_service.dart';
import 'package:androidircx/core/platform/screen_security.dart';
import 'package:androidircx/core/security/secret_storage.dart';
Expand Down Expand Up @@ -35,28 +36,33 @@ class AndroidIrcxApp extends StatefulWidget {
class _AndroidIrcxAppState extends State<AndroidIrcxApp> {
late final AppSettingsController _settingsController;
bool? _appliedScreenSecure;
bool? _appliedAnalyticsConsent;

@override
void initState() {
super.initState();
_settingsController = AppSettingsController(
repository: widget.settingsRepository,
);
_settingsController.addListener(_applyScreenSecurity);
_settingsController.addListener(_applySettingsSideEffects);
_settingsController.load();
}

void _applyScreenSecurity() {
final secure = _settingsController.settings.screenshotProtection;
if (secure != _appliedScreenSecure) {
_appliedScreenSecure = secure;
unawaited(const ScreenSecurity().setSecure(secure));
void _applySettingsSideEffects() {
final settings = _settingsController.settings;
if (settings.screenshotProtection != _appliedScreenSecure) {
_appliedScreenSecure = settings.screenshotProtection;
unawaited(const ScreenSecurity().setSecure(settings.screenshotProtection));
}
if (settings.analyticsConsent != _appliedAnalyticsConsent) {
_appliedAnalyticsConsent = settings.analyticsConsent;
unawaited(FirebaseService.instance.setConsent(settings.analyticsConsent));
}
}

@override
void dispose() {
_settingsController.removeListener(_applyScreenSecurity);
_settingsController.removeListener(_applySettingsSideEffects);
_settingsController.dispose();
super.dispose();
}
Expand Down
95 changes: 95 additions & 0 deletions lib/core/firebase/firebase_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import 'dart:async';

import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_app_check/firebase_app_check.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';

/// Owns the Firebase integration: App Check (Play Integrity, anti-abuse and
/// always on), plus Analytics and Crashlytics whose data collection stays OFF
/// until the user consents. Uncaught Flutter/platform errors are chained into
/// Crashlytics on top of any existing handlers (e.g. the email crash reporter).
class FirebaseService {
FirebaseService();

/// App-wide instance used by `main`, the settings consent toggle and the
/// onboarding step.
static final FirebaseService instance = FirebaseService();

bool _initialized = false;
bool _consent = false;

bool get isInitialized => _initialized;
FirebaseAnalytics? get analytics =>
_initialized ? FirebaseAnalytics.instance : null;

/// Initializes Firebase and App Check, and routes uncaught errors into
/// Crashlytics. Safe to call once; never throws to the caller.
Future<void> initialize() async {
if (_initialized) {
return;
}
await Firebase.initializeApp();
await FirebaseAppCheck.instance.activate(
providerAndroid:
kReleaseMode ? AndroidPlayIntegrityProvider() : AndroidDebugProvider(),
);
_initialized = true;

// Collection stays off until the user consents; apply the default now.
await _applyConsent(_consent);

final priorFlutterHandler = FlutterError.onError;
FlutterError.onError = (details) {
priorFlutterHandler?.call(details);
// No-op unless Crashlytics collection is enabled (consent given).
unawaited(
FirebaseCrashlytics.instance.recordFlutterError(details, fatal: true),
);
};

final priorPlatformHandler = PlatformDispatcher.instance.onError;
PlatformDispatcher.instance.onError = (error, stack) {
unawaited(
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true),
);
return priorPlatformHandler?.call(error, stack) ?? false;
};
}

/// Enables/disables Analytics and Crashlytics collection to match consent.
Future<void> setConsent(bool consent) async {
_consent = consent;
await _applyConsent(consent);
}

Future<void> _applyConsent(bool consent) async {
if (!_initialized) {
return;
}
try {
await FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(consent);
await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(
consent,
);
} catch (_) {
// Best effort; never let telemetry toggling crash the app.
}
}

/// Logs a named analytics event (no-op unless initialized + consented).
Future<void> logEvent(String name, {Map<String, Object>? parameters}) async {
if (!_initialized || !_consent) {
return;
}
try {
await FirebaseAnalytics.instance.logEvent(
name: name,
parameters: parameters,
);
} catch (_) {
// Ignore analytics failures.
}
}
}
18 changes: 18 additions & 0 deletions lib/core/models/app_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class AppSettings {
this.nickColorMode = NickColorMode.soft,
this.onboardingCompleted = false,
this.appLockEnabled = false,
this.analyticsConsent = false,
this.notificationsEnabled = false,
this.notifyHighlights = true,
this.notifyPrivateMessages = true,
this.notifyDccOffers = true,
Expand Down Expand Up @@ -60,6 +62,14 @@ class AppSettings {
final bool appLockEnabled;

// Notifications.
/// Whether the user consented to Firebase Analytics + Crashlytics data
/// collection. Off by default; collection stays disabled until this is true.
final bool analyticsConsent;

/// Master switch for notifications. Stays false until the OS notification
/// permission (POST_NOTIFICATIONS) is granted; the per-type toggles below
/// only take effect while this is true.
final bool notificationsEnabled;
final bool notifyHighlights;
final bool notifyPrivateMessages;
final bool notifyDccOffers;
Expand Down Expand Up @@ -102,6 +112,8 @@ class AppSettings {
NickColorMode? nickColorMode,
bool? onboardingCompleted,
bool? appLockEnabled,
bool? analyticsConsent,
bool? notificationsEnabled,
bool? notifyHighlights,
bool? notifyPrivateMessages,
bool? notifyDccOffers,
Expand Down Expand Up @@ -140,6 +152,8 @@ class AppSettings {
nickColorMode: nickColorMode ?? this.nickColorMode,
onboardingCompleted: onboardingCompleted ?? this.onboardingCompleted,
appLockEnabled: appLockEnabled ?? this.appLockEnabled,
analyticsConsent: analyticsConsent ?? this.analyticsConsent,
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
notifyHighlights: notifyHighlights ?? this.notifyHighlights,
notifyPrivateMessages:
notifyPrivateMessages ?? this.notifyPrivateMessages,
Expand Down Expand Up @@ -177,6 +191,8 @@ class AppSettings {
'nickColorMode': nickColorMode.name,
'onboardingCompleted': onboardingCompleted,
'appLockEnabled': appLockEnabled,
'analyticsConsent': analyticsConsent,
'notificationsEnabled': notificationsEnabled,
'notifyHighlights': notifyHighlights,
'notifyPrivateMessages': notifyPrivateMessages,
'notifyDccOffers': notifyDccOffers,
Expand Down Expand Up @@ -232,6 +248,8 @@ class AppSettings {
),
onboardingCompleted: (json['onboardingCompleted'] as bool?) ?? false,
appLockEnabled: (json['appLockEnabled'] as bool?) ?? false,
analyticsConsent: (json['analyticsConsent'] as bool?) ?? false,
notificationsEnabled: (json['notificationsEnabled'] as bool?) ?? false,
notifyHighlights: (json['notifyHighlights'] as bool?) ?? true,
notifyPrivateMessages: (json['notifyPrivateMessages'] as bool?) ?? true,
notifyDccOffers: (json['notifyDccOffers'] as bool?) ?? true,
Expand Down
53 changes: 53 additions & 0 deletions lib/core/platform/app_permissions.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import 'package:permission_handler/permission_handler.dart';

enum AppPermissionResult { granted, denied, permanentlyDenied, restricted }

/// Thin wrapper over `permission_handler` so runtime-permission flows are
/// testable (the concrete implementation talks to the OS; tests inject a fake).
abstract class AppPermissions {
Future<AppPermissionResult> requestNotifications();
Future<bool> hasNotifications();
Future<AppPermissionResult> requestCamera();
Future<bool> hasCamera();

/// Opens the OS app-settings page (used after a permanent denial).
Future<void> openSettingsPage();
}

class PermissionHandlerAppPermissions implements AppPermissions {
const PermissionHandlerAppPermissions();

static AppPermissionResult _map(PermissionStatus status) {
if (status.isGranted || status.isLimited) {
return AppPermissionResult.granted;
}
if (status.isPermanentlyDenied) {
return AppPermissionResult.permanentlyDenied;
}
if (status.isRestricted) {
return AppPermissionResult.restricted;
}
return AppPermissionResult.denied;
}

@override
Future<AppPermissionResult> requestNotifications() async =>
_map(await Permission.notification.request());

@override
Future<bool> hasNotifications() async =>
(await Permission.notification.status).isGranted;

@override
Future<AppPermissionResult> requestCamera() async =>
_map(await Permission.camera.request());

@override
Future<bool> hasCamera() async =>
(await Permission.camera.status).isGranted;

@override
Future<void> openSettingsPage() async {
await openAppSettings();
}
}
10 changes: 10 additions & 0 deletions lib/features/chat/application/chat_session_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5436,6 +5436,16 @@ class ChatSessionController extends ChangeNotifier {
}

bool _notificationEnabledFor(ForegroundNotificationChannelKind kind) {
// The ongoing connection/foreground-service notice is always attempted so
// the app is reachable from the background; the OS shows it once the user
// has granted notification permission.
if (kind == ForegroundNotificationChannelKind.connection) {
return true;
}
// Every other alert requires the user to have opted into notifications.
if (!_settings.notificationsEnabled) {
return false;
}
switch (kind) {
case ForegroundNotificationChannelKind.highlights:
return _settings.notifyHighlights;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Future<void> showServerDirectoryPicker(
final selected = await showModalBottomSheet<ServerPreset>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) {
return SafeArea(
child: ListView(
Expand Down
10 changes: 7 additions & 3 deletions lib/features/onboarding/presentation/data_privacy_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,13 @@ class DataPrivacyScreen extends StatelessWidget {
'protocol; use TLS and SASL for privacy in transit.',
),
const _PrivacyPoint(
icon: Icons.cloud_off_outlined,
title: 'No analytics or ads',
body: 'This build contains no advertising or analytics SDKs.',
icon: Icons.insights_outlined,
title: 'Optional analytics & crash reports',
body:
'No ads at the moment 🙂. Anonymous usage analytics and crash '
'reports (Firebase Analytics/Crashlytics) are OFF by default '
'and only collected if you opt in; you can change this any '
'time in Settings.',
),
const SizedBox(height: 16),
FilledButton.icon(
Expand Down
Loading
Loading