chore(deps): upgrade to AGP 9.3.1, Gradle 9.6.1, Kotlin 2.4.10, SDK 37 (#750)

* chore(deps): upgrade to AGP 9.3.1, Gradle 9.6.1, Kotlin 2.4.10, SDK 37

Bring the toolchain and every dependency to latest stable. No app source
changes were required.

Toolchain:
- AGP 8.10.1 -> 9.3.1, Gradle 8.13 -> 9.6.1, Kotlin 2.2.0 -> 2.4.10
- compileSdk 35 -> 37, targetSdk 35 -> 37 (Android 17, stable)
- Java 8 -> 11

AGP 9 migration (built-in Kotlin):
- Drop org.jetbrains.kotlin.android; AGP 9 provides Kotlin natively and the
  plugin is incompatible with the new DSL
- Migrate kotlinOptions.jvmTarget to kotlin.compilerOptions (the String
  setter is a hard error in Kotlin 2.4)
- Drop android.enableJetifier (deprecated, removed in AGP 10, no support
  library deps remain)

Libraries:
- Compose BOM 2025.06.01 -> 2026.06.01, activity-compose 1.10.1 -> 1.13.0
- core-ktx 1.16.0 -> 1.19.0, lifecycle 2.9.1 -> 2.11.0 (unified with
  lifecycle-process, which had drifted to 2.8.7)
- okhttp 4.12.0 -> 5.4.0, coroutines 1.10.2 -> 1.11.0, gson 2.13.1 -> 2.14.0
- BouncyCastle 1.70 -> 1.85, switching bcprov-jdk15on -> bcprov-jdk18on
  (jdk15on is abandoned; same org.bouncycastle packages)
- Tink 1.10.0 -> 1.23.0, CameraX 1.5.2 -> 1.6.1, gms-location 21.3.0 -> 21.4.0
- security-crypto 1.1.0-beta01 -> 1.1.0, navigation-compose 2.9.1 -> 2.9.8
- exifinterface 1.3.7 -> 1.4.2, moved from a hardcoded coordinate into the
  version catalog
- Tests: espresso 3.6.1 -> 3.7.0, test-ext 1.2.1 -> 1.3.0, mockito-kotlin
  4.1.0 -> 6.3.0; mockito-inline (deprecated) -> mockito-core 5.23.0;
  coroutines-test 1.6 -> 1.11.0, now sharing the coroutines version ref
  instead of drifting

Robolectric stays pinned at 4.15: 4.16+ breaks EncryptionServiceTest with
"AndroidKeyStore not found". Bisected away from security-crypto and shown not
to be SDK-level related. Unpinning needs an EncryptionService refactor, which
is deliberately left to a follow-up PR.

targetSdk behaviour changes for API 36 and 37 were audited against the source:
edge-to-edge and predictive back are already handled, ACCESS_LOCAL_NETWORK is
not needed (loopback only, for Arti's SOCKS proxy), the reflection in
ChatViewModel touches instance rather than static final fields, and there is
no RFCOMM or scheduleAtFixedRate usage.

Verified: compileDebugKotlin, testDebugUnitTest (96 tests, 0 failures),
bundleRelease with R8, gradlew help, and build --dry-run. The 6 R8 "cannot
parse kotlin metadata" warnings present under AGP 8.13.2 are gone under 9.3.1.

Not verified on hardware. BLE mesh, foreground services, Nostr relay
websockets, Tor, and the Noise handshake still need a device smoke test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: declare ACCESS_LOCAL_NETWORK for Wi-Fi Aware on Android 17

Android 17 (API 37) makes local network protection mandatory for apps
targeting it. WifiAwareMeshService reaches peers over link-local IPv6 TCP
sockets (connectAwareClientSocket), which may be gated by the new
ACCESS_LOCAL_NETWORK runtime permission once targetSdk is raised to 37.

The official local network permission documentation frames the feature as
LAN access and does not explicitly state whether Wi-Fi Aware peer-to-peer
networks are in scope, so this is defensive rather than confirmed-necessary.
The sockets are bound to a dedicated Aware Network obtained via
requestNetwork, not the user's subnet.

Declaring it costs nothing: ACCESS_LOCAL_NETWORK shares the NEARBY_DEVICES
group with NEARBY_WIFI_DEVICES, so users who have already granted the latter
are not prompted again. The runtime request is gated on SDK_INT >= 37 so
older devices are unaffected.

Raised by automated review on #750.

Verified: compileDebugKotlin, testDebugUnitTest (124 tests, 0 failures),
bundleRelease, and ACCESS_LOCAL_NETWORK present in the merged manifest.
Not verified on an Android 17 device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct ACCESS_LOCAL_NETWORK permission-group claim

Device testing on Android 17 (API 37) disproved the earlier claim that
ACCESS_LOCAL_NETWORK is effectively free because it shares the NEARBY_DEVICES
group with NEARBY_WIFI_DEVICES.

Granting NEARBY_WIFI_DEVICES alone leaves ACCESS_LOCAL_NETWORK denied:

  pm grant ... NEARBY_WIFI_DEVICES
  -> NEARBY_WIFI_DEVICES:  granted=true
  -> ACCESS_LOCAL_NETWORK: granted=false

The two are tracked and granted independently, so ACCESS_LOCAL_NETWORK has to
be requested explicitly. That is exactly what the wifiAwarePermissions() list
already does, so no behavioural change is needed — only the comments were
wrong. Whether the runtime dialog bundles the two into a single prompt remains
unverified, since enabling Wi-Fi Aware from Debug Settings after onboarding
never triggers a permission request at all (pre-existing, unrelated to this
branch).

Comment-only change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: request Wi-Fi Aware permissions when enabling it from Debug Settings

Enabling Wi-Fi Aware from the Debug Settings sheet never requested the
permissions it needs. The permission flow is reachable only through
PermissionManager.getRequiredPermissions(), which gates the Wi-Fi Aware entries
behind shouldRequireWifiAwarePermission() — and that returns false unless the
debug toggle is already on. Since the toggle defaults to off, onboarding never
asks, and flipping it later starts WifiAwareController directly, which only
checks the permission and bails.

The result was a silent dead end: Wi-Fi Aware could never start, and the
controller logged "Missing NEARBY_WIFI_DEVICES permission" on a 5s retry loop
indefinitely. Reproduced on a Pixel 9a (Android 17) and a Samsung SM-A366E
(Android 16); both needed adb grants to get the transport running at all.

The toggle and the Start chip now request the permissions first and only enable
the transport once NEARBY_WIFI_DEVICES is granted. ACCESS_LOCAL_NETWORK is
treated as best-effort since it does not exist below API 37 — confirmed by
`pm grant` rejecting it as an unknown permission on the Android 16 device. The
list comes from PermissionManager.wifiAwarePermissions() so the API 37 gate has
a single definition.

Also corrects the permission-group comments now that both levels are verified
on Android 17: grants are tracked independently (granting NEARBY_WIFI_DEVICES
alone leaves ACCESS_LOCAL_NETWORK denied), but the two share the NEARBY_DEVICES
group so requesting them together produces a single "Nearby devices" prompt.

Verified on device: after a clean uninstall/reinstall, toggling Wi-Fi Aware
produced one prompt and left both permissions granted with the USER_SET flag.

Addresses the second automated review finding on #750.

Verified: compileDebugKotlin, testDebugUnitTest (124 tests, 0 failures).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: version-gate Wi-Fi Aware permissions, check live grant state

Two issues from automated review of ee1ee3ad.

wifiAwarePermissions() returned NEARBY_WIFI_DEVICES unconditionally, but that
permission only exists from API 33 while minSdk is 26 and Wi-Fi Aware is
available from API 26. On an API 26-32 device the new enable path would request
an unknown permission, receive a denial, and never enable a transport that
needs no runtime permission there at all — a regression introduced by the
previous commit. Both entries are now version-gated.

The result callback also inferred the Nearby grant from the result map, which
omits permissions that were already held and so filtered out before launching.
It now reads the live permission state instead.

A denied ACCESS_LOCAL_NETWORK still does not block enabling: the controller
starts fine without it (verified on Android 17), and its necessity for
link-local sockets remains unproven, so a denial should not disable a transport
that otherwise works.

Also trims the comments added in the last two commits down to the density of
the surrounding code.

Verified: compileDebugKotlin, testDebugUnitTest (124 tests, 0 failures).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moe Hamade 2026-07-27 00:34:08 +03:00 committed by GitHub
parent 84b24e347f
commit 61588db474
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 110 additions and 48 deletions

View File

@ -1,6 +1,7 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.compose)
}
@ -65,11 +66,8 @@ android {
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
@ -86,6 +84,12 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies {
// Core Android dependencies
implementation(libs.androidx.core.ktx)
@ -143,7 +147,7 @@ dependencies {
implementation(libs.androidx.security.crypto)
// EXIF orientation handling for images
implementation("androidx.exifinterface:exifinterface:1.3.7")
implementation(libs.androidx.exifinterface)
// Testing
testImplementation(libs.bundles.testing)

View File

@ -27,6 +27,8 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Android 13+ runtime permission for WiFi operations (including Aware) -->
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<!-- Android 17+ gates local network access; WiFi Aware peers over link-local IPv6 -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Signature permission for internal UI shutdown broadcasts -->
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
<!-- Foreground service and boot permissions for long-running background mesh -->

View File

@ -39,6 +39,26 @@ class PermissionManager(private val context: Context) {
}
}
/**
* Runtime permissions for the WiFi Aware transport, version-gated because neither exists
* at minSdk 26 requesting an unknown permission comes back permanently denied.
*
* ACCESS_LOCAL_NETWORK is defensive: Android 17 gates local network access, and the
* transport reaches peers over link-local IPv6 sockets. It is granted separately from
* NEARBY_WIFI_DEVICES but shares its permission group, so the two prompt only once.
*/
fun wifiAwarePermissions(): List<String> {
val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES)
}
// API 37 == Android 17; no named VERSION_CODES constant is available yet.
if (Build.VERSION.SDK_INT >= 37) {
permissions.add(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
return permissions
}
/**
* Check if this is the first time the user is launching the app
*/
@ -87,7 +107,7 @@ class PermissionManager(private val context: Context) {
// WiFi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission
if (shouldRequireWifiAwarePermission()) {
permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES)
permissions.addAll(wifiAwarePermissions())
}
// Notification permission intentionally excluded to keep it optional
@ -232,7 +252,7 @@ class PermissionManager(private val context: Context) {
// WiFi Aware category (Android 13+)
if (shouldRequireWifiAwarePermission()) {
val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES)
val wifiAwarePermissions = wifiAwarePermissions()
categories.add(
PermissionCategory(
type = PermissionType.WIFI_AWARE,

View File

@ -37,6 +37,11 @@ import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.compose.ui.platform.LocalContext
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.bitchat.android.onboarding.PermissionManager
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
@ -128,6 +133,37 @@ fun DebugSettingsSheet(
val bleEnabled by manager.bleEnabled.collectAsState()
val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState()
val wifiAwareVerbose by manager.wifiAwareVerbose.collectAsState()
// Onboarding only asks for these when the toggle is already on, and it defaults to off,
// so enabling from here has to request them or the controller never starts.
val wifiAwarePermissions = remember { PermissionManager(context).wifiAwarePermissions() }
val wifiAwarePermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { _ ->
// Check live state, not the result map — already-held permissions are filtered out
// before launching. Only NEARBY_WIFI_DEVICES blocks startup; the other is defensive.
val nearbyPermission = android.Manifest.permission.NEARBY_WIFI_DEVICES
val nearbyGranted = nearbyPermission !in wifiAwarePermissions ||
ContextCompat.checkSelfPermission(context, nearbyPermission) ==
PackageManager.PERMISSION_GRANTED
if (nearbyGranted) {
manager.setWifiAwareEnabled(true)
} else {
manager.addDebugMessage(
DebugMessage.SystemMessage("WiFi Aware needs the Nearby devices permission")
)
}
}
val enableWifiAware: () -> Unit = {
val missing = wifiAwarePermissions.filter {
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) {
manager.setWifiAwareEnabled(true)
} else {
wifiAwarePermissionLauncher.launch(missing.toTypedArray())
}
}
val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState()
val wifiAwareConnected by manager.wifiAwareConnected.collectAsState()
val wifiAwareSupported by com.bitchat.android.wifiaware.WifiAwareController.supported.collectAsState()
@ -345,7 +381,9 @@ fun DebugSettingsSheet(
Switch(
checked = wifiAwareEnabled && wifiAwareSupported,
enabled = wifiSwitchEnabled,
onCheckedChange = { manager.setWifiAwareEnabled(it) }
onCheckedChange = { on ->
if (on) enableWifiAware() else manager.setWifiAwareEnabled(false)
}
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
@ -643,7 +681,7 @@ fun DebugSettingsSheet(
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
AssistChip(
onClick = { manager.setWifiAwareEnabled(true) },
onClick = enableWifiAware,
enabled = wifiAwareSupported,
label = { Text("Start") }
)

View File

@ -1,7 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false
}

View File

@ -9,9 +9,6 @@
# Specifies the JVM arguments used for the daemon process.
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects

View File

@ -1,72 +1,72 @@
[versions]
# Android and Kotlin
agp = "8.10.1"
kotlin = "2.2.0"
compileSdk = "35"
agp = "9.3.1"
kotlin = "2.4.10"
compileSdk = "37"
minSdk = "26" # API 26 for proper BLE support
targetSdk = "35"
targetSdk = "37"
# AndroidX Core
core-ktx = "1.16.0"
lifecycle-runtime = "2.9.1"
activity-compose = "1.10.1"
core-ktx = "1.19.0"
lifecycle-runtime = "2.11.0"
activity-compose = "1.13.0"
appcompat = "1.7.1"
# Compose
compose-bom = "2025.06.01"
compose-bom = "2026.06.01"
# Navigation
navigation-compose = "2.9.1"
navigation-compose = "2.9.8"
# Accompanist
accompanist-permissions = "0.37.3"
# Cryptography
bouncycastle = "1.70"
tink-android = "1.10.0"
bouncycastle = "1.85"
tink-android = "1.23.0"
# JSON
gson = "2.13.1"
gson = "2.14.0"
# Coroutines
kotlinx-coroutines = "1.10.2"
kotlinx-coroutines = "1.11.0"
# Bluetooth
nordic-ble = "2.6.1"
nordic-ble = "2.11.0"
# WebSocket
okhttp = "4.12.0"
okhttp = "5.4.0"
tor-android-binary = "0.4.4.6"
# Google Play Services
gms-location = "21.3.0"
gms-location = "21.4.0"
# Security
security-crypto = "1.1.0-beta01"
security-crypto = "1.1.0"
# QR
zxing-core = "3.5.4"
# EXIF
exifinterface = "1.4.2"
# CameraX / ML Kit
camerax = "1.5.2"
camerax = "1.6.1"
mlkit-barcode = "17.3.0"
# Testing
junit = "4.13.2"
androidx-test-ext = "1.2.1"
espresso = "3.6.1"
mockito-kotlin = "4.1.0"
mockito-inline = "4.1.0"
roboelectric = "4.15"
kotlinx-coroutines-test = "1.6"
lifecycle-process = "2.8.7"
androidx-test-ext = "1.3.0"
espresso = "3.7.0"
mockito-kotlin = "6.3.0"
mockito-core = "5.23.0"
roboelectric = "4.15" # 4.16+ drops the AndroidKeyStore shim; breaks EncryptionServiceTest
[libraries]
# AndroidX Core
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-process" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-runtime" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle-runtime" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
@ -90,7 +90,7 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist-permissions" }
# Cryptography
bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" }
bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" }
google-tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink-android" }
# JSON
@ -117,6 +117,9 @@ androidx-security-crypto = { module = "androidx.security:security-crypto", versi
# QR
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing-core" }
# EXIF
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" }
# CameraX / ML Kit
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }
@ -130,14 +133,13 @@ androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core",
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" }
mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito-core" }
roboelectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric"}
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-parcelize = { id = "kotlin-parcelize" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
@ -165,7 +167,7 @@ testing = [
"androidx-test-ext-junit",
"androidx-test-espresso-core",
"mockito-kotlin",
"mockito-inline",
"mockito-core",
"roboelectric",
"kotlinx-coroutines-test"
]

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME