From 165847da1ca3c3be627b5968bc77e9a11b3385cb Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 17 Oct 2026 13:29:25 +0200 Subject: feat(android): ask for the local network when a group opens Android 17 makes it a permission for an application targeting SDK 37. Co-Authored-By: Claude Opus 5.5 --- .../app/src/main/AndroidManifest.xml | 5 ++ .../app/src/main/assets/bridge/meshbay-bridge.js | 8 ++ .../main/kotlin/org/meshbay/client/MainActivity.kt | 6 +- .../kotlin/org/meshbay/client/bridge/Channels.kt | 3 + .../org/meshbay/client/shell/LocalNetwork.kt | 94 ++++++++++++++++++++++ .../kotlin/org/meshbay/client/LocalNetworkTest.kt | 28 +++++++ 6 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/LocalNetwork.kt create mode 100644 packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/LocalNetworkTest.kt (limited to 'packages/meshbay-android/app/src') diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml index 6b97b451..6cd9b90f 100644 --- a/packages/meshbay-android/app/src/main/AndroidManifest.xml +++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml @@ -8,6 +8,11 @@ + + diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js index d829df34..e8b40b2d 100644 --- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js +++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js @@ -104,6 +104,14 @@ resolveStun: (urls) => call('ice:resolve-stun', urls), + // Android 17's permission to reach the local network, where the node + // usually is: its state, the question, and this application's settings. + localNetwork: { + state: () => call('lan:state'), + request: () => call('lan:request'), + openSettings: () => call('lan:settings'), + }, + // The device's hub key: generated, held and used natively. The page asks // for a signature and never sees a key — it parses hostile input. device: { diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt index 5f225e26..7ec81693 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt @@ -48,6 +48,7 @@ import org.meshbay.client.save.SaveSinks import org.meshbay.client.shell.Pickers import org.meshbay.client.shell.ShellWebView import org.meshbay.client.shell.EngineCheck +import org.meshbay.client.shell.LocalNetwork import org.meshbay.client.shell.NativeText import org.meshbay.client.shell.UiAssets import java.util.concurrent.CountDownLatch @@ -66,6 +67,7 @@ class MainActivity : Activity() { private lateinit var hub: HubClient private lateinit var channels: Channels private lateinit var photos: PhotoChannels + private lateinit var lan: LocalNetwork private var docs: List = emptyList() private var drives: List = emptyList() /** Backups exist in the personal profile only (Profiles.kt). */ @@ -128,6 +130,7 @@ class MainActivity : Activity() { java.io.File(cacheDir, "calendarsync"), CalendarSource(this), notifyId = 12, permissionRequest = 4211), ) + lan = LocalNetwork(this, getSharedPreferences("shell", Context.MODE_PRIVATE)) channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } }, hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves, cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } }, @@ -136,7 +139,7 @@ class MainActivity : Activity() { Notifier.CHANNEL_CHAT to text.get("push.channel_chat", channels.locale), Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) }), photos = photos.takeIf { backups }, destinations = destinations.takeIf { backups }, - docs = docs, drives = drives) + docs = docs, drives = drives, lan = lan) WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels)) cast.control.warmUp() installShim() @@ -373,6 +376,7 @@ class MainActivity : Activity() { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { if (::photos.isInitialized && photos.deliverPermission(requestCode)) return + if (::lan.isInitialized && lan.deliverPermission(requestCode)) return if (docs.any { it.deliverPermission(requestCode) }) return super.onRequestPermissionsResult(requestCode, permissions, grantResults) } diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt index e3a81df6..19b0f3c2 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt @@ -10,6 +10,7 @@ import org.meshbay.client.phonesync.DocChannels import org.meshbay.client.photos.PhotoChannels import org.meshbay.client.save.BinaryFrame import org.meshbay.client.save.SaveSinks +import org.meshbay.client.shell.LocalNetwork import java.net.Inet4Address import java.net.InetAddress @@ -36,6 +37,7 @@ class Channels( private val destinations: DestinationChannels? = null, private val docs: List = emptyList(), private val drives: List = emptyList(), + private val lan: LocalNetwork? = null, ) { @Volatile var locale = "en" private set @@ -62,6 +64,7 @@ class Channels( "save:open" -> saves().open(args.optLong(0, -1)) else -> when { keys != null && keys.handles(channel) -> keys.call(channel, args) + lan != null && lan.handles(channel) -> lan.call(channel, args) cast != null && cast.handles(channel) -> cast.call(channel, args) push != null && push.handles(channel) -> push.call(channel, args) photos != null && photos.handles(channel) -> photos.call(channel, args) diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/LocalNetwork.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/LocalNetwork.kt new file mode 100644 index 00000000..b92f677e --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/LocalNetwork.kt @@ -0,0 +1,94 @@ +package org.meshbay.client.shell + +import android.app.Activity +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import org.json.JSONArray +import org.meshbay.client.bridge.Refused +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * The local network, which Android 17 makes a permission (ACCESS_LOCAL_NETWORK, + * in the "Nearby devices" group) for an application that targets it. + * + * The node is usually on the Wi-Fi the phone is on, and the page reaches it + * through the WebView, whose traffic carries this application's permission. + * Without it the node's addresses on that Wi-Fi are dropped and only its public + * address is tried, which a home router seldom loops back: the group opens on + * mobile data and not at home. Asked for when a group is first opened, never at + * start (platform.js `localNetwork`, group-page.js). + * + * Phone-only: the desktop preload has no counterpart, so `platform.localNetwork` + * is absent there. + */ +class LocalNetwork(private val activity: Activity, private val prefs: SharedPreferences) { + @Volatile private var waiting: CountDownLatch? = null + + fun handles(channel: String) = channel.startsWith("lan:") + + fun call(channel: String, args: JSONArray): Any? = when (channel) { + "lan:state" -> state() + "lan:request" -> request() + "lan:settings" -> { openSettings(); true } + else -> throw Refused("Refused: no such channel") + } + + fun state(): String = stateOf( + sdk = Build.VERSION.SDK_INT, + granted = Build.VERSION.SDK_INT >= ENFORCED_FROM && + activity.checkSelfPermission(PERMISSION) == PackageManager.PERMISSION_GRANTED, + asked = prefs.getBoolean(ASKED, false), + rationale = Build.VERSION.SDK_INT >= ENFORCED_FROM && + activity.shouldShowRequestPermissionRationale(PERMISSION)) + + /** Asks, and waits for the answer: the page goes on from what was decided. */ + private fun request(): String { + if (state() != "prompt") return state() + val latch = CountDownLatch(1) + waiting = latch + activity.runOnUiThread { activity.requestPermissions(arrayOf(PERMISSION), PERMISSION_REQUEST) } + latch.await(5, TimeUnit.MINUTES) + waiting = null + prefs.edit().putBoolean(ASKED, true).apply() + return state() + } + + /** From Activity.onRequestPermissionsResult; true when the request was ours. */ + fun deliverPermission(requestCode: Int): Boolean { + if (requestCode != PERMISSION_REQUEST) return false + waiting?.countDown() + return true + } + + /** This application's page in the system settings, where a refusal is undone. */ + private fun openSettings() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", activity.packageName, null)) + activity.runOnUiThread { activity.startActivity(intent) } + } + + companion object { + const val PERMISSION = android.Manifest.permission.ACCESS_LOCAL_NETWORK + /** Android 17. Before it the permission is implied by INTERNET. */ + const val ENFORCED_FROM = 37 + private const val PERMISSION_REQUEST = 4213 + private const val ASKED = "lan_asked" + + /** + * "granted"; "prompt" while Android will still ask; "denied" once only + * the settings can change it. Android says which by the rationale flag, + * false both before the first question and after the last refusal, so + * whether it was ever asked is kept here. + */ + fun stateOf(sdk: Int, granted: Boolean, asked: Boolean, rationale: Boolean): String = when { + sdk < ENFORCED_FROM || granted -> "granted" + !asked || rationale -> "prompt" + else -> "denied" + } + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/LocalNetworkTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/LocalNetworkTest.kt new file mode 100644 index 00000000..cc5d3bac --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/LocalNetworkTest.kt @@ -0,0 +1,28 @@ +package org.meshbay.client + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.meshbay.client.shell.LocalNetwork + +class LocalNetworkTest { + private fun state(sdk: Int = 37, granted: Boolean = false, asked: Boolean = false, rationale: Boolean = false) = + LocalNetwork.stateOf(sdk, granted, asked, rationale) + + @Test fun `before Android 17 the local network comes with the internet`() { + assertEquals("granted", state(sdk = 36)) + assertEquals("granted", state(sdk = 26, asked = true)) + } + + @Test fun `a permission given is granted`() { + assertEquals("granted", state(granted = true)) + } + + @Test fun `never asked, or refused once, Android still asks`() { + assertEquals("prompt", state()) + assertEquals("prompt", state(asked = true, rationale = true)) + } + + @Test fun `refused for good, only the settings can change it`() { + assertEquals("denied", state(asked = true, rationale = false)) + } +} -- cgit v1.2.3