Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
8be4e92
feat(mobile): bridge Android age signals
Aug 4, 2026
0d86e46
feat(mobile): gate app with store age signals
Aug 4, 2026
4ded04c
test(mobile): cover authenticated age gate
Aug 4, 2026
b6769a0
Merge origin/main into mobile-age-signal-gate
brow Sep 1, 2026
4fbb4fd
Merge origin/main into mobile-age-signal-gate
brow Sep 1, 2026
3ab80dd
fix(mobile): retry transient age signal failures
brow Sep 1, 2026
0a46c3f
Merge origin/main into mobile-age-signal-gate
brow Sep 1, 2026
d4a7643
fix(mobile): gate app until age signal resolves
brow Sep 1, 2026
f456988
fix(mobile): defer relay until age check
brow Sep 1, 2026
1dda22d
fix(mobile): keep age gate fail closed
brow Sep 1, 2026
87703cd
fix(mobile): revoke push access behind age gate
brow Sep 1, 2026
41bacd5
fix(mobile): keep age gate fail-closed
brow Sep 1, 2026
226d706
fix(mobile): fence restricted snapshot cleanup
brow Sep 1, 2026
cb2d079
fix(mobile): surface malformed age signals
brow Sep 1, 2026
4a83c4e
fix(mobile): reject missing age responses
brow Sep 1, 2026
ccfa2e9
fix(mobile): suspend push until age check passes
brow Sep 1, 2026
fd0f65a
fix(mobile): retry age snapshot transitions
brow Sep 1, 2026
14247d2
Fix age-gate snapshot retries
brow Sep 1, 2026
868e772
Keep restricted snapshot retries active
brow Sep 1, 2026
c67193b
Time out stalled age signal requests
brow Sep 1, 2026
1dc6521
Fence timed-out age signal requests
brow Sep 1, 2026
6acc4ca
Retire exhausted age signal requests
brow Sep 2, 2026
4f3570c
Preserve uncancellable age signal flights
brow Sep 2, 2026
012d5b7
Restart after uncancellable age signal stalls
brow Sep 2, 2026
fa69510
Purge notifications after age restriction
brow Sep 2, 2026
65a8182
Retry restricted push cleanup
brow Sep 2, 2026
ecdd5bc
Fence restricted notification delivery
brow Sep 2, 2026
7cdaa7f
Close restricted cleanup fail-open paths
brow Sep 2, 2026
cb4269a
Fence age-check snapshot suspension
brow Sep 2, 2026
aa496c1
fix(mobile): purge donated interactions when age restricted
brow Sep 2, 2026
d695f74
fix(mobile): retry stale interaction cleanup
brow Sep 2, 2026
43a54cf
fix(mobile): bound restriction maintenance purges
brow Sep 2, 2026
59a4cbf
fix(mobile): clear badge during age gate
brow Sep 2, 2026
3b370c0
fix(mobile): fence legacy notification state
brow Sep 2, 2026
7dc2e49
fix(mobile): serialize age restriction fencing
brow Sep 2, 2026
f6dbc08
fix(mobile): bound restricted notification purge
brow Sep 2, 2026
d57ceba
fix(mobile): serialize notification handoff
brow Sep 2, 2026
12283f8
fix(mobile): fence push before Flutter launch
brow Sep 2, 2026
72c40af
Merge main into mobile age signal gate
brow Sep 8, 2026
f9bc153
fix(mobile): allow unsupported Play age signal environments
brow Sep 8, 2026
dc90177
fix(mobile): acknowledge in-process age signal recovery
brow Sep 8, 2026
de5bfe8
fix(mobile): bound late donation interaction cleanup
brow Sep 8, 2026
475f273
Fix exclusive iOS age signal boundary
brow Sep 8, 2026
4961f73
Keep notification snapshot updates coherent across age transitions
brow Sep 14, 2026
be88a99
Hand off safe notification content before asynchronous cleanup
brow Sep 14, 2026
d2ccfe8
Keep age-gate startup recoverable when notification protection fails
brow Sep 14, 2026
d9f1331
Update rustls to fix RUSTSEC-2026-0285
brow Sep 14, 2026
ff6b505
Serialize notification updates through age-gate restoration
brow Sep 14, 2026
6ba37f8
Use valid credentials in invite recovery fixtures
brow Sep 14, 2026
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
19 changes: 10 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 10 additions & 10 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions mobile/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ android {
}

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

testImplementation(kotlin("test"))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package xyz.block.buzz.mobile

import android.content.Intent

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
Expand All @@ -10,6 +12,13 @@ import android.media.MediaMetadataRetriever
import android.media.MediaMuxer
import android.os.Build
import androidx.annotation.RequiresApi
import com.google.android.play.agesignals.AgeSignalsException
import com.google.android.play.agesignals.model.AgeSignalsErrorCode
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.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
Expand All @@ -18,6 +27,46 @@ 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 fun replyWithAgeSignalError(
result: MethodChannel.Result,
error: Exception,
) {
// Missing/outdated Play installations and non-Play installs cannot supply
// a signal. Preserve Buzz's unsupported-environment no-signal policy.
// Transport, binding, SDK integration, and unknown failures stay gated.
if (error is AgeSignalsException && error.errorCode in setOf(
AgeSignalsErrorCode.API_NOT_AVAILABLE,
AgeSignalsErrorCode.PLAY_STORE_NOT_FOUND,
AgeSignalsErrorCode.PLAY_SERVICES_NOT_FOUND,
AgeSignalsErrorCode.PLAY_STORE_VERSION_OUTDATED,
AgeSignalsErrorCode.PLAY_SERVICES_VERSION_OUTDATED,
AgeSignalsErrorCode.APP_NOT_OWNED,
)
) {
result.success(noAgeSignalPayload())
return
}
result.error(
"age_signal_unavailable",
"The age signal request failed.",
error.javaClass.simpleName,
)
}

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

class MainActivity : FlutterFragmentActivity() {
private var mediaUploadChannel: MethodChannel? = null
private var ageSignalChannel: MethodChannel? = null
private var ageSignalRequestGeneration = 0
private var pendingAgeSignalResult: MethodChannel.Result? = null
private var huddleMediaPlugin: HuddleMediaPlugin? = null

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
Expand Down Expand Up @@ -117,6 +169,107 @@ class MainActivity : FlutterFragmentActivity() {
}
}
}

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,
)
}
CANCEL_AGE_SIGNAL_METHOD -> cancelAgeSignalRequest(result)
RESTART_AGE_SIGNAL_METHOD -> restartForAgeSignal(result)
else -> result.notImplemented()
}
}
}
}

private fun handleRequestAgeSignal(
ageSignalsManager: AgeSignalsManager,
result: MethodChannel.Result,
) {
if (pendingAgeSignalResult != null) {
result.error("age_signal_in_flight", "An age signal request is already active.", null)
return
}
ageSignalRequestGeneration += 1
val generation = ageSignalRequestGeneration
pendingAgeSignalResult = result
val accessRequest = AgeSignalsAccessRequest.builder()
.setActivity(this)
.build()
ageSignalsManager.requestAgeSignalsAccess(accessRequest)
.addOnSuccessListener { accessResult ->
if (accessResult.ageSignalsStatus() != AgeSignalsStatus.SHARED) {
completeAgeSignalRequest(generation, result) { replyWithNoAgeSignal(result) }
return@addOnSuccessListener
}

ageSignalsManager.checkAgeSignals(AgeSignalsRequest.builder().build())
.addOnSuccessListener { ageSignalsResult ->
completeAgeSignalRequest(generation, result) {
replyWithAgeSignal(result, ageSignalsResult.ageUpper())
}
}
.addOnFailureListener { error ->
completeAgeSignalRequest(generation, result) {
replyWithAgeSignalError(result, error)
}
}
}
.addOnFailureListener { error ->
completeAgeSignalRequest(generation, result) {
replyWithAgeSignalError(result, error)
}
Comment thread
brow marked this conversation as resolved.
}
}

private fun completeAgeSignalRequest(
generation: Int,
result: MethodChannel.Result,
reply: () -> Unit,
) {
if (generation != ageSignalRequestGeneration || pendingAgeSignalResult !== result) return
pendingAgeSignalResult = null
reply()
}

private fun cancelAgeSignalRequest(result: MethodChannel.Result) {
// Play age-signals 0.0.4 exposes non-cancellable Tasks. Retain the
// original single flight rather than allowing an overlapping prompt.
result.success(false)
Comment thread
brow marked this conversation as resolved.
}

private fun restartForAgeSignal(result: MethodChannel.Result) {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
if (launchIntent == null) {
result.error("age_signal_restart_failed", "Buzz could not restart.", null)
return
}
result.success(false)
window.decorView.post {
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(launchIntent)
finishAffinity()
Runtime.getRuntime().exit(0)
}
}

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

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

override fun onRequestPermissionsResult(
Expand Down Expand Up @@ -388,6 +541,10 @@ class MainActivity : FlutterFragmentActivity() {

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 CANCEL_AGE_SIGNAL_METHOD = "cancelAgeSignalRequest"
private const val RESTART_AGE_SIGNAL_METHOD = "restartForAgeSignal"
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
Loading
Loading