aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-android/app')
-rw-r--r--packages/meshbay-android/app/build.gradle.kts4
-rw-r--r--packages/meshbay-android/app/src/main/AndroidManifest.xml5
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js8
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt6
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt3
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/LocalNetwork.kt94
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/LocalNetworkTest.kt28
7 files changed, 146 insertions, 2 deletions
diff --git a/packages/meshbay-android/app/build.gradle.kts b/packages/meshbay-android/app/build.gradle.kts
index 7887d042..c981710e 100644
--- a/packages/meshbay-android/app/build.gradle.kts
+++ b/packages/meshbay-android/app/build.gradle.kts
@@ -17,7 +17,9 @@ android {
defaultConfig {
applicationId = "org.meshbay.client"
minSdk = 26
- targetSdk = 36
+ // Android 17: reaching the local network is a permission the person
+ // grants (shell/LocalNetwork.kt), as it already was for Chrome 155.
+ targetSdk = 37
versionName = packageVersion
versionCode = versionParts[0] * 10000 + versionParts[1] * 100 + versionParts[2]
}
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 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+ <!-- The node is usually on the Wi-Fi the phone is on. Android 17 asks the
+ person before an application that targets it reaches the local
+ network, and the page's connections carry this application's
+ permission. Asked for when a group is first opened, never at start. -->
+ <uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Notifications from the hub, fetched or pushed; asked for when the
person turns them on, never at start. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
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<DocChannels> = emptyList()
private var drives: List<DriveChannels> = 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<out String>, 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<DocChannels> = emptyList(),
private val drives: List<DriveChannels> = 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))
+ }
+}