Skip to content
Draft
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
2 changes: 2 additions & 0 deletions mobile/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ android {
}

dependencies {
implementation("com.google.android.play:age-signals:0.0.4")

testImplementation(kotlin("test"))

androidTestImplementation(kotlin("test"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import android.media.MediaExtractor
import android.media.MediaMuxer
import android.os.Build
import androidx.annotation.RequiresApi
import com.google.android.play.agesignals.AgeSignalsAccessRequest
import com.google.android.play.agesignals.AgeSignalsManager
import com.google.android.play.agesignals.AgeSignalsManagerFactory
import com.google.android.play.agesignals.AgeSignalsRequest
import com.google.android.play.agesignals.model.AgeSignalsStatus
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
Expand All @@ -17,6 +22,20 @@ import java.io.File
import java.nio.ByteBuffer
import java.util.UUID

internal fun ageSignalPayload(ageUpper: Int?): Map<String, Any?> {
return mapOf(
"status" to "signal",
"ageUpper" to ageUpper,
)
}

internal fun noAgeSignalPayload(): Map<String, Any?> {
return mapOf(
"status" to "noSignal",
"ageUpper" to null,
)
}

internal object AndroidImageProcessor {
fun decodeSrgbBitmap(bytes: ByteArray): Bitmap? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Expand Down Expand Up @@ -78,6 +97,7 @@ internal object AndroidImageProcessor {

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

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Expand All @@ -104,6 +124,61 @@ class MainActivity : FlutterActivity() {
}
}
}

ageSignalChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
AGE_SIGNAL_CHANNEL,
).also { channel ->
channel.setMethodCallHandler { call, result ->
when (call.method) {
REQUEST_AGE_SIGNAL_METHOD -> {
handleRequestAgeSignal(
AgeSignalsManagerFactory.create(applicationContext),
result,
)
}
else -> result.notImplemented()
}
}
}
}

private fun handleRequestAgeSignal(
ageSignalsManager: AgeSignalsManager,
result: MethodChannel.Result,
) {
val accessRequest = AgeSignalsAccessRequest.builder()
.setActivity(this)
.build()
ageSignalsManager.requestAgeSignalsAccess(accessRequest)
.addOnSuccessListener { accessResult ->
if (accessResult.ageSignalsStatus() != AgeSignalsStatus.SHARED) {
replyWithNoAgeSignal(result)
return@addOnSuccessListener
}

ageSignalsManager.checkAgeSignals(AgeSignalsRequest.builder().build())
.addOnSuccessListener { ageSignalsResult ->
replyWithAgeSignal(result, ageSignalsResult.ageUpper())
}
.addOnFailureListener {
replyWithNoAgeSignal(result)
}
}
.addOnFailureListener {
replyWithNoAgeSignal(result)
}
}

private fun replyWithAgeSignal(
result: MethodChannel.Result,
ageUpper: Int?,
) {
result.success(ageSignalPayload(ageUpper))
}

private fun replyWithNoAgeSignal(result: MethodChannel.Result) {
result.success(noAgeSignalPayload())
}

private fun handleSanitizeImageForUpload(
Expand Down Expand Up @@ -284,6 +359,8 @@ class MainActivity : FlutterActivity() {

companion object {
private const val MEDIA_UPLOAD_CHANNEL = "buzz/media_upload"
private const val AGE_SIGNAL_CHANNEL = "buzz/age_signal"
private const val REQUEST_AGE_SIGNAL_METHOD = "requestAgeSignal"
private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload"
private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg"
private const val TRANSCODE_VIDEO_TO_MP4_METHOD = "transcodeVideoToMp4"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package xyz.block.buzz.mobile

import kotlin.test.Test
import kotlin.test.assertEquals

class AgeSignalPayloadTest {
@Test
fun `signal payload contains only status and upper age bound`() {
assertEquals(
mapOf(
"status" to "signal",
"ageUpper" to 17,
),
ageSignalPayload(17),
)
assertEquals(
mapOf(
"status" to "signal",
"ageUpper" to null,
),
ageSignalPayload(null),
)
}

@Test
fun `no-signal payload contains only status and null upper age bound`() {
assertEquals(
mapOf(
"status" to "noSignal",
"ageUpper" to null,
),
noAgeSignalPayload(),
)
}
}
57 changes: 57 additions & 0 deletions mobile/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AVFoundation
import DeclaredAgeRange
import Flutter
import UIKit
import UserNotifications
Expand All @@ -8,6 +9,7 @@ import UserNotifications
private var mediaUploadChannel: FlutterMethodChannel?
private var qrScannerChannel: FlutterMethodChannel?
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var ageSignalChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?

override func application(
Expand Down Expand Up @@ -51,6 +53,21 @@ import UserNotifications
}
}

ageSignalChannel = FlutterMethodChannel(
name: "buzz/age_signal",
binaryMessenger: messenger
)
let ageSignalViewController = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzAgeSignal"
)?.viewController
ageSignalChannel?.setMethodCallHandler { [weak ageSignalViewController] call, result in
Self.handleAgeSignalMethodCall(
call,
viewController: ageSignalViewController,
result: result
)
}

if let inlinePhotoPickerRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzInlinePhotoPicker"
) {
Expand All @@ -72,6 +89,46 @@ import UserNotifications
)
}

private static func handleAgeSignalMethodCall(
_ call: FlutterMethodCall,
viewController: UIViewController?,
result: @escaping FlutterResult
) {
guard call.method == "requestAgeSignal" else {
result(FlutterMethodNotImplemented)
return
}
guard #available(iOS 26.0, *), let viewController else {
result(Self.noAgeSignalResponse)
return
}

Task { @MainActor in
do {
let response = try await AgeRangeService.shared.requestAgeRange(
ageGates: 18,
in: viewController
)
switch response {
case .declinedSharing:
result(Self.noAgeSignalResponse)
case .sharing(let range):
let ageUpper = range.upperBound.map { $0 as Any } ?? NSNull()
result(["status": "signal", "ageUpper": ageUpper])
@unknown default:
result(Self.noAgeSignalResponse)
}
} catch {
result(Self.noAgeSignalResponse)
}
}
}

private static let noAgeSignalResponse: [String: Any] = [
"status": "noSignal",
"ageUpper": NSNull(),
]

private static func handleQrScannerMethodCall(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
Expand Down
22 changes: 17 additions & 5 deletions mobile/lib/app.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import 'dart:async';

import 'package:app_badge_plus/app_badge_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'features/age_gate/age_restriction_page.dart';
import 'features/age_gate/age_signal_provider.dart';
import 'features/channels/unread_badge/unread_badge_provider.dart';
import 'features/home/home_page.dart';
import 'features/pairing/pairing_page.dart';
Expand All @@ -28,6 +32,14 @@ class App extends HookConsumerWidget {
final accentIndex = ref.watch(accentProvider);
final schemeName = ref.watch(schemeProvider);
final authState = ref.watch(authProvider);
final ageRestricted = ref.watch(ageSignalProvider);

useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(ref.read(ageSignalProvider.notifier).request());
});
return null;
}, const []);

final resolved = resolveSchemes(schemeName, themeMode);
final lightScheme = applyAccent(resolved.light, accentIndex);
Expand Down Expand Up @@ -90,11 +102,11 @@ class App extends HookConsumerWidget {
topSectionGradient: buzzDarkGradient,
),
themeMode: effectiveMode,
// Above the navigator, so a burst keeps playing over a pushed thread page
// or a modal sheet — the same reason desktop pins its canvas to the
// viewport rather than to the message row.
builder: (context, child) =>
EmojiBurstOverlay(child: child ?? const SizedBox.shrink()),
// Above the navigator, so an age restriction cannot be bypassed by a
// route that was pushed while the store signal request was in flight.
builder: (context, child) => ageRestricted
? const AgeRestrictionPage()
: EmojiBurstOverlay(child: child ?? const SizedBox.shrink()),
home: authState.when(
loading: () => const _SplashScreen(),
error: (_, _) => const PairingPage(),
Expand Down
49 changes: 49 additions & 0 deletions mobile/lib/features/age_gate/age_restriction_page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';

import '../../shared/theme/theme.dart';

class AgeRestrictionPage extends StatelessWidget {
const AgeRestrictionPage({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(Grid.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lock_outline,
size: 48,
color: context.colors.primary,
),
const SizedBox(height: Grid.lg),
Text(
'Buzz is for people 18 and older',
textAlign: TextAlign.center,
style: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.sm),
Text(
"You must be 18 or older to use Buzz under Buzz's Terms.",
textAlign: TextAlign.center,
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
),
),
),
);
}
}
58 changes: 58 additions & 0 deletions mobile/lib/features/age_gate/age_signal_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

const ageSignalChannel = MethodChannel('buzz/age_signal');

bool shouldBlockForAgeSignal(Map<Object?, Object?> response) {
if (response.length != 2 ||
!response.containsKey('status') ||
!response.containsKey('ageUpper')) {
throw StateError('Unexpected age signal response.');
}

final status = response['status'];
if (status == 'noSignal') {
return false;
}
if (status != 'signal') {
throw StateError('Unexpected age signal status.');
}

final ageUpper = response['ageUpper'];
if (ageUpper == null) {
return false;
}
if (ageUpper is! int) {
throw StateError('Unexpected age signal upper bound.');
}
return ageUpper < 18;
}

class AgeSignalNotifier extends Notifier<bool> {
bool _requested = false;

@override
bool build() => false;

Future<void> request() async {
if (_requested) {
return;
}
_requested = true;

try {
final response = await ageSignalChannel.invokeMapMethod<Object?, Object?>(
'requestAgeSignal',
);
if (response != null) {
state = shouldBlockForAgeSignal(response);
}
} on Object {
state = false;
}
}
}

final ageSignalProvider = NotifierProvider<AgeSignalNotifier, bool>(
AgeSignalNotifier.new,
);
Loading
Loading