summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-17 13:29:25 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-17 13:29:25 +0200
commit165847da1ca3c3be627b5968bc77e9a11b3385cb (patch)
tree603329a801d1304fdba833c1a8f4cd8c4ce4e7c6 /packages
parenta1d18a2957d167444c6fc6c2e4ec8628509a7009 (diff)
downloadmeshbay-165847da1ca3c3be627b5968bc77e9a11b3385cb.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-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
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js14
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py33
20 files changed, 275 insertions, 6 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))
+ }
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 728ffc37..45049a16 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -31,6 +31,10 @@ import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js';
// each try is a connection.
const DEVICE_RETRY_MS = 5000;
+// Whether this application has asked for the local network yet in this run:
+// once, when the first group is opened, and again only when the person says so.
+let lanAsked = false;
+
/** The node's `expires_at` (ISO) in epoch seconds; 0 when it gave none. */
function _untilSeconds(iso) {
const ms = Date.parse(iso || '');
@@ -240,6 +244,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const [passInput, setPassInput] = useState('');
const [passBusy, setPassBusy] = useState(false);
const [retryKey, setRetryKey] = useState(0);
+ // In the Android application, when the local network was refused: whether
+ // Android will still ask ("prompt") or only its settings can change it.
+ const [lanBlocked, setLanBlocked] = useState('');
const transportRef = useRef(null);
const gekRef = useRef(null);
// One refresh per group: if a fresh token still says we are not a member, we
@@ -398,6 +405,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const connect = async () => {
setStatus('discovering');
setError('');
+ setLanBlocked('');
// Belongs to the connection about to be made, not the group just left.
setDeviceReady(false);
gekRef.current = null;
@@ -425,6 +433,16 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// from the node, or creates one there on a first join.
const identity = null;
+ // Android 17 asks before this application reaches the local network,
+ // where the node usually is: when a group is first opened rather than
+ // at start, and a refusal is explained below if it is why the group
+ // does not open (local_network_blocked).
+ if (platform.localNetwork.available && !lanAsked) {
+ lanAsked = true;
+ try { await platform.localNetwork.request(); } catch { /* the connection says the rest */ }
+ if (cancelled) return;
+ }
+
setStatus('connecting');
// Renewed here rather than taken from the prop. This effect no longer
// re-runs when the token rotates (see the dependency list below), so
@@ -706,7 +724,14 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// This device dropped the node's local addresses (transport.js
// localNetworkRefused): a setting to change here, not a node to wait
// for. Android 17 gates it per application, Chrome from 155.
- if (err.reason === 'local_network_blocked') {
+ if (err.reason === 'local_network_blocked' && platform.localNetwork.available) {
+ err.message = t('group.lan_blocked_app');
+ try {
+ const state = await platform.localNetwork.state();
+ if (cancelled) return;
+ setLanBlocked(state === 'granted' ? '' : state);
+ } catch { /* the message stands alone */ }
+ } else if (err.reason === 'local_network_blocked') {
err.message = t(/Android/i.test(navigator.userAgent)
? 'group.lan_blocked_android' : 'group.lan_blocked_browser');
}
@@ -1038,6 +1063,25 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
<button class="admin-btn" style="margin-left:8px;font-size:0.9em"
onClick=${() => setRetryKey(k => k + 1)}>${t('group.retry')}</button>
</div>`}
+ ${lanBlocked === 'prompt' && html`
+ <button class="admin-btn" style="margin-bottom:12px" onClick=${async () => {
+ try { await platform.localNetwork.request(); } catch { /* retried either way */ }
+ setRetryKey(k => k + 1);
+ }}>${t('group.lan_allow')}</button>`}
+ ${lanBlocked === 'denied' && html`
+ <p class="settings-hint">${t('group.lan_settings_hint')}</p>
+ <button class="admin-btn" style="margin-bottom:12px" onClick=${() => {
+ // Back from the settings is back in the foreground: try again then.
+ const back = () => {
+ if (document.visibilityState !== 'visible') return;
+ document.removeEventListener('visibilitychange', back);
+ setRetryKey(k => k + 1);
+ };
+ document.addEventListener('visibilitychange', back);
+ platform.localNetwork.openSettings().catch(() => {
+ document.removeEventListener('visibilitychange', back);
+ });
+ }}>${t('group.lan_open_settings')}</button>`}
${needsDevice && nativeKeys && html`
<div class="invite-form" style="margin-bottom:12px">
<h4>${t('device.add_title')}</h4>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index c121cee0..ae2dcfbf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -1302,6 +1302,10 @@ export default {
'group.bundle_format_retired': 'Dieser Node speichert Ihre Identität in einem Format, das diese Version nicht mehr liest. Bitten Sie den Betreiber, „meshbay-node member unpin" für Ihr Konto auszuführen und Ihnen einen neuen Einladungscode zu senden.',
'group.lan_blocked_android': "Android erlaubt diesem Browser nicht, Geräte in Ihrem lokalen Netzwerk zu erreichen, und der Node dieser Gruppe ist dort. Öffnen Sie Android-Einstellungen › Apps › Chrome (oder Ihr Browser) › Berechtigungen › Geräte in der Nähe, erlauben Sie den Zugriff und tippen Sie dann auf Erneut versuchen. Über mobile Daten öffnet sich die Gruppe auch ohne.",
'group.lan_blocked_browser': "Dieser Browser hat es abgelehnt, Geräte in Ihrem lokalen Netzwerk zu erreichen, und der Node dieser Gruppe ist dort. Erlauben Sie dieser Website den Zugriff auf das lokale Netzwerk (Symbol links neben der Adressleiste) und versuchen Sie es dann erneut.",
+ 'group.lan_blocked_app': "MeshBay darf keine Geräte in Ihrem lokalen Netzwerk erreichen, und der Node dieser Gruppe ist dort.",
+ 'group.lan_allow': "Erlauben",
+ 'group.lan_settings_hint': "Android fragt nicht noch einmal. Wählen Sie in den Einstellungen, die diese Schaltfläche öffnet, Berechtigungen › Geräte in der Nähe › Zulassen und kehren Sie dann hierher zurück.",
+ 'group.lan_open_settings': "Einstellungen öffnen",
'settings.browser_access': 'Browserzugang',
'settings.browser_access_on_hint': 'Ein Browser kann Ihre Gruppen mit Ihrer Passphrase öffnen: Diese Anwendung legt Ihre Identität auf jedem Node ab, so versiegelt, dass nur Ihre Passphrase zusammen mit Ihrem Hub-Konto sie öffnen kann.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 0dd8448f..e5ed9cb3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1287,6 +1287,10 @@ export default {
'group.bundle_format_retired': 'This node holds your identity in a format this version no longer reads. Ask its operator to run “meshbay-node member unpin” for your account and send you a new invitation code.',
'group.lan_blocked_android': "Android does not let this browser reach devices on your local network, and this group's node is on it. Open Android Settings › Apps › Chrome (or the browser you use) › Permissions › Nearby devices, allow it, then tap Retry. On mobile data the group opens without it.",
'group.lan_blocked_browser': "This browser refused to reach devices on your local network, and this group's node is on it. Allow local network access for this site (the icon left of the address bar), then Retry.",
+ 'group.lan_blocked_app': "MeshBay is not allowed to reach devices on your local network, and this group's node is on it.",
+ 'group.lan_allow': "Allow",
+ 'group.lan_settings_hint': "Android will not ask again. In the settings this button opens, choose Permissions › Nearby devices › Allow, then come back here.",
+ 'group.lan_open_settings': "Open settings",
'settings.browser_access': 'Browser access',
'settings.browser_access_on_hint': 'A browser can open your groups with your passphrase: this application leaves your identity on each node, sealed so that only your passphrase together with your hub account can open it.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 8bc6efbf..9801f741 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -1297,6 +1297,10 @@ export default {
'group.bundle_format_retired': 'Este node guarda su identidad en un formato que esta versión ya no lee. Pida a su operador que ejecute «meshbay-node member unpin» para su cuenta y le envíe un nuevo código de invitación.',
'group.lan_blocked_android': "Android no permite que este navegador acceda a los dispositivos de su red local, y el node de este grupo está en ella. Abra Ajustes de Android › Aplicaciones › Chrome (o el navegador que use) › Permisos › Dispositivos cercanos, permítalo y toque Reintentar. Con datos móviles, el grupo se abre sin este permiso.",
'group.lan_blocked_browser': "Este navegador se negó a acceder a los dispositivos de su red local, y el node de este grupo está en ella. Permita el acceso a la red local para este sitio (el icono a la izquierda de la barra de direcciones) y vuelva a intentarlo.",
+ 'group.lan_blocked_app': "MeshBay no tiene permiso para acceder a los dispositivos de su red local, y el node de este grupo está en ella.",
+ 'group.lan_allow': "Permitir",
+ 'group.lan_settings_hint': "Android no volverá a preguntar. En los ajustes que abre este botón, elija Permisos › Dispositivos cercanos › Permitir y vuelva aquí.",
+ 'group.lan_open_settings': "Abrir ajustes",
'settings.browser_access': 'Acceso desde el navegador',
'settings.browser_access_on_hint': 'Un navegador puede abrir sus grupos con su frase de contraseña: esta aplicación deja su identidad en cada node, sellada de modo que solo su frase de contraseña, junto con su cuenta del hub, pueda abrirla.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 125a2891..b0dfd37b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -1312,6 +1312,10 @@ export default {
'group.bundle_format_retired': 'Ce node détient votre identité dans un format que cette version ne lit plus. Demandez à son opérateur d\'exécuter « meshbay-node member unpin » pour votre compte et de vous envoyer un nouveau code d\'invitation.',
'group.lan_blocked_android': "Android n’autorise pas ce navigateur à joindre les appareils de votre réseau local, où se trouve le node de ce groupe. Ouvrez Paramètres Android › Applis › Chrome (ou votre navigateur) › Autorisations › Appareils à proximité, autorisez, puis touchez Réessayer. En données mobiles, le groupe s’ouvre sans cette autorisation.",
'group.lan_blocked_browser': "Ce navigateur a refusé de joindre les appareils de votre réseau local, où se trouve le node de ce groupe. Autorisez l’accès au réseau local pour ce site (icône à gauche de la barre d’adresse), puis Réessayer.",
+ 'group.lan_blocked_app': "MeshBay n’a pas l’autorisation de joindre les appareils de votre réseau local, où se trouve le node de ce groupe.",
+ 'group.lan_allow': "Autoriser",
+ 'group.lan_settings_hint': "Android ne posera plus la question. Dans les paramètres qu’ouvre ce bouton, choisissez Autorisations › Appareils à proximité › Autoriser, puis revenez ici.",
+ 'group.lan_open_settings': "Ouvrir les paramètres",
'settings.browser_access': 'Accès depuis un navigateur',
'settings.browser_access_on_hint': 'Un navigateur peut ouvrir vos groupes avec votre phrase secrète : cette application dépose votre identité sur chaque node, scellée de sorte que seule votre phrase secrète, avec votre compte sur le hub, puisse l\'ouvrir.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index e7990b9a..22e09deb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -1311,6 +1311,10 @@ export default {
'group.bundle_format_retired': 'Questo node conserva la tua identità in un formato che questa versione non legge più. Chiedi al suo operatore di eseguire «meshbay-node member unpin» per il tuo account e di inviarti un nuovo codice di invito.',
'group.lan_blocked_android': "Android non consente a questo browser di raggiungere i dispositivi della tua rete locale, dove si trova il node di questo gruppo. Apri Impostazioni Android › App › Chrome (o il browser che usi) › Autorizzazioni › Dispositivi nelle vicinanze, consenti, poi tocca Riprova. Con i dati mobili il gruppo si apre anche senza.",
'group.lan_blocked_browser': "Questo browser ha rifiutato di raggiungere i dispositivi della tua rete locale, dove si trova il node di questo gruppo. Consenti l’accesso alla rete locale per questo sito (l’icona a sinistra della barra degli indirizzi), poi Riprova.",
+ 'group.lan_blocked_app': "MeshBay non è autorizzata a raggiungere i dispositivi della tua rete locale, dove si trova il node di questo gruppo.",
+ 'group.lan_allow': "Consenti",
+ 'group.lan_settings_hint': "Android non lo chiederà più. Nelle impostazioni aperte da questo pulsante, scegli Autorizzazioni › Dispositivi nelle vicinanze › Consenti, poi torna qui.",
+ 'group.lan_open_settings': "Apri impostazioni",
'settings.browser_access': 'Accesso dal browser',
'settings.browser_access_on_hint': 'Un browser può aprire i tuoi gruppi con la tua passphrase: questa applicazione lascia la tua identità su ogni node, sigillata in modo che solo la tua passphrase, insieme al tuo account sull\'hub, possa aprirla.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 24450a3d..a4645ac3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -1295,6 +1295,10 @@ export default {
'group.bundle_format_retired': 'この node は、このバージョンでは読めなくなった形式であなたの ID を保持しています。運用者に、あなたのアカウントに対して「meshbay-node member unpin」を実行し、新しい招待コードを送るよう依頼してください。',
'group.lan_blocked_android': "Android がこのブラウザにローカルネットワーク上のデバイスへの接続を許可していません。このグループの node はそのネットワーク上にあります。Android の設定 › アプリ › Chrome(またはお使いのブラウザ)› 権限 › 付近のデバイス を開いて許可し、再試行をタップしてください。モバイルデータでは、この権限がなくてもグループを開けます。",
'group.lan_blocked_browser': "このブラウザはローカルネットワーク上のデバイスへの接続を拒否しました。このグループの node はそのネットワーク上にあります。このサイトにローカルネットワークへのアクセスを許可し(アドレスバー左のアイコン)、再試行してください。",
+ 'group.lan_blocked_app': "MeshBay にはローカルネットワーク上のデバイスへの接続が許可されていません。このグループの node はそのネットワーク上にあります。",
+ 'group.lan_allow': "許可",
+ 'group.lan_settings_hint': "Android はもう確認しません。このボタンで開く設定で「権限」›「付近のデバイス」›「許可」を選び、ここに戻ってください。",
+ 'group.lan_open_settings': "設定を開く",
'settings.browser_access': 'ブラウザーからのアクセス',
'settings.browser_access_on_hint': 'ブラウザーはパスフレーズであなたのグループを開けます。このアプリは各 node にあなたの ID を残し、パスフレーズと hub のアカウントの両方がそろった場合にのみ開けるよう封印します。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index e37875ca..0f31f6cb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -1313,6 +1313,10 @@ export default {
'group.bundle_format_retired': 'Deze node bewaart je identiteit in een formaat dat deze versie niet meer leest. Vraag de beheerder om "meshbay-node member unpin" voor je account uit te voeren en je een nieuwe uitnodigingscode te sturen.',
'group.lan_blocked_android': "Android staat deze browser niet toe apparaten in je lokale netwerk te bereiken, en de node van deze groep staat daar. Open Android-instellingen › Apps › Chrome (of je browser) › Rechten › Apparaten in de buurt, sta het toe en tik dan op Opnieuw proberen. Via mobiele data opent de groep ook zonder.",
'group.lan_blocked_browser': "Deze browser weigerde apparaten in je lokale netwerk te bereiken, en de node van deze groep staat daar. Sta toegang tot het lokale netwerk toe voor deze site (het pictogram links van de adresbalk) en probeer het opnieuw.",
+ 'group.lan_blocked_app': "MeshBay mag geen apparaten in je lokale netwerk bereiken, en de node van deze groep staat daar.",
+ 'group.lan_allow': "Toestaan",
+ 'group.lan_settings_hint': "Android vraagt het niet opnieuw. Kies in de instellingen die deze knop opent Rechten › Apparaten in de buurt › Toestaan en kom dan hier terug.",
+ 'group.lan_open_settings': "Instellingen openen",
'settings.browser_access': 'Browsertoegang',
'settings.browser_access_on_hint': 'Een browser kan je groepen openen met je wachtzin: deze toepassing laat je identiteit achter op elke node, zo verzegeld dat alleen je wachtzin samen met je hub-account haar kan openen.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 7ae5f9a2..c89a60d3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1341,6 +1341,10 @@ export default {
'group.bundle_format_retired': 'Ten node przechowuje Twoją tożsamość w formacie, którego ta wersja już nie odczytuje. Poproś jego operatora o uruchomienie „meshbay-node member unpin" dla Twojego konta i przesłanie nowego kodu zaproszenia.',
'group.lan_blocked_android': "Android nie pozwala tej przeglądarce łączyć się z urządzeniami w Twojej sieci lokalnej, a node tej grupy jest w niej. Otwórz Ustawienia Androida › Aplikacje › Chrome (lub Twoja przeglądarka) › Uprawnienia › Urządzenia w pobliżu, zezwól, a potem stuknij Ponów. Przez dane komórkowe grupa otwiera się i bez tego.",
'group.lan_blocked_browser': "Ta przeglądarka odmówiła połączenia z urządzeniami w Twojej sieci lokalnej, a node tej grupy jest w niej. Zezwól tej witrynie na dostęp do sieci lokalnej (ikona po lewej stronie paska adresu), a potem ponów.",
+ 'group.lan_blocked_app': "MeshBay nie ma zgody na łączenie się z urządzeniami w Twojej sieci lokalnej, a node tej grupy jest w niej.",
+ 'group.lan_allow': "Zezwól",
+ 'group.lan_settings_hint': "Android nie zapyta ponownie. W ustawieniach otwartych tym przyciskiem wybierz Uprawnienia › Urządzenia w pobliżu › Zezwalaj, a potem wróć tutaj.",
+ 'group.lan_open_settings': "Otwórz ustawienia",
'settings.browser_access': 'Dostęp z przeglądarki',
'settings.browser_access_on_hint': 'Przeglądarka może otworzyć Twoje grupy Twoim hasłem: ta aplikacja zostawia Twoją tożsamość na każdym node, zapieczętowaną tak, by otworzyć ją mogło tylko Twoje hasło wraz z kontem na hubie.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 9098dcb7..aa33e177 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -1298,6 +1298,10 @@ export default {
'group.bundle_format_retired': 'Este node guarda sua identidade em um formato que esta versão não lê mais. Peça ao operador que execute "meshbay-node member unpin" para sua conta e envie um novo código de convite.',
'group.lan_blocked_android': "O Android não permite que este navegador acesse os dispositivos da sua rede local, e o node deste grupo está nela. Abra Configurações do Android › Apps › Chrome (ou o navegador que você usa) › Permissões › Dispositivos por perto, permita e toque em Tentar novamente. Com dados móveis, o grupo abre mesmo sem isso.",
'group.lan_blocked_browser': "Este navegador se recusou a acessar os dispositivos da sua rede local, e o node deste grupo está nela. Permita o acesso à rede local para este site (o ícone à esquerda da barra de endereço) e tente novamente.",
+ 'group.lan_blocked_app': "O MeshBay não tem permissão para acessar os dispositivos da sua rede local, e o node deste grupo está nela.",
+ 'group.lan_allow': "Permitir",
+ 'group.lan_settings_hint': "O Android não vai perguntar de novo. Nas configurações que este botão abre, escolha Permissões › Dispositivos por perto › Permitir e volte aqui.",
+ 'group.lan_open_settings': "Abrir configurações",
'settings.browser_access': 'Acesso pelo navegador',
'settings.browser_access_on_hint': 'Um navegador pode abrir seus grupos com sua frase secreta: este aplicativo deixa sua identidade em cada node, selada de forma que apenas sua frase secreta, junto com sua conta no hub, possa abri-la.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 5a2bc3ca..27f20dc7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -1284,6 +1284,10 @@ export default {
'group.bundle_format_retired': '此 node 以本版本不再读取的格式保存您的身份。请其运营者为您的账户运行“meshbay-node member unpin”,并向您发送新的邀请码。',
'group.lan_blocked_android': "Android 不允许此浏览器访问您局域网中的设备,而此群组的 node 就在该网络中。请打开 Android 设置 › 应用 › Chrome(或您使用的浏览器)› 权限 › 附近的设备,允许后点按重试。使用移动数据时,无需此权限也能打开群组。",
'group.lan_blocked_browser': "此浏览器拒绝访问您局域网中的设备,而此群组的 node 就在该网络中。请允许此网站访问本地网络(地址栏左侧的图标),然后重试。",
+ 'group.lan_blocked_app': "MeshBay 无权访问您局域网中的设备,而此群组的 node 就在该网络中。",
+ 'group.lan_allow': "允许",
+ 'group.lan_settings_hint': "Android 不会再次询问。请在此按钮打开的设置中选择“权限”›“附近的设备”›“允许”,然后返回此处。",
+ 'group.lan_open_settings': "打开设置",
'settings.browser_access': '浏览器访问',
'settings.browser_access_on_hint': '浏览器可以用您的密码短语打开您的群组:本应用会在每个 node 上留下您的身份,并加以封存,只有您的密码短语配合您的 hub 账户才能打开。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 3a91417d..d773200f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -93,6 +93,20 @@ export const capabilities = {
};
/**
+ * Android 17's permission to reach the local network, in the Android
+ * application (shell/LocalNetwork.kt): `state()` is "granted", "prompt" while
+ * Android will still ask, or "denied" once only the settings can change it.
+ * Absent in a browser, which holds that permission itself and where a page
+ * cannot ask for it, and on the desktop, where there is no such permission.
+ */
+export const localNetwork = {
+ available: Boolean(bridge && bridge.localNetwork),
+ state() { return bridge.localNetwork.state(); },
+ request() { return bridge.localNetwork.request(); },
+ openSettings() { return bridge.localNetwork.openSettings(); },
+};
+
+/**
* The app's secret store, as far as the page may know it.
*
* The store is the main process's (the OS keychain through safeStorage) and it
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
index 520fa7e7..cba4b320 100644
--- a/packages/meshbay-hub/tests/test_android_shell.py
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -137,7 +137,8 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each():
native = set()
for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt",
SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt",
- SRC / "photos" / "PhotoChannels.kt", SRC / "phonesync" / "Destination.kt"):
+ SRC / "photos" / "PhotoChannels.kt", SRC / "phonesync" / "Destination.kt",
+ SRC / "shell" / "LocalNetwork.kt"):
native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M))
# Contacts and the calendar share one class, its channels named by a prefix.
docs = set(re.findall(r'^\s*"([\w-]+)" ->', _read(SRC / "phonesync" / "DocChannels.kt"), flags=re.M))
@@ -151,10 +152,11 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each():
assert shim, "no channel found in the shim"
# A phone has a push distributor to talk to and a camera roll and an
# address book to back up, and a desktop has none of them; those families are the ones the desktop lacks
- # rather than the ones it shares.
+ # rather than the ones it shares. Nor does a desktop ask before reaching the
+ # local network (Android 17).
phone_only = {c for c in shim if c.startswith(("push:", "photosync:", "phonesync:", "contactsync:",
"calendarsync:", "drivesync:",
- "wasync:"))}
+ "wasync:", "lan:"))}
assert any(c.startswith("push:") for c in phone_only), "the push channels are gone"
assert any(c.startswith("photosync:") for c in phone_only), "the backup channels are gone"
assert shim - phone_only <= preload, f"channels the desktop does not have: {shim - preload}"
@@ -236,6 +238,31 @@ def test_a_notification_is_drawn_only_when_it_opened_and_leads_inside_the_page()
assert 'location.hash = ${JSONObject.quote(link)}' in activity
+def test_the_local_network_is_asked_for_when_a_group_opens():
+ """Android 17 makes the local network a permission for an application that
+ targets it, and the node is usually on the phone's Wi-Fi: without it the
+ group opens on mobile data and not at home. The page's WebRTC carries this
+ application's permission. Asked when a group is first opened, never at
+ start, and only by an application that targets 37: before that the
+ permission comes with INTERNET and asking for it is what Android says not
+ to do."""
+ manifest = _read(APP / "src" / "main" / "AndroidManifest.xml")
+ assert "android.permission.ACCESS_LOCAL_NETWORK" in manifest
+ target = int(re.search(r"targetSdk = (\d+)", _read(APP / "build.gradle.kts")).group(1))
+ assert target >= 37, "the permission is declared by an application that does not target 37"
+ activity = _read(SRC / "MainActivity.kt")
+ on_create = activity.split("override fun onCreate(", 1)[1].split("\n override fun ", 1)[0]
+ assert "requestPermissions" not in on_create
+ assert "lan.deliverPermission(requestCode)" in activity
+ lan = _read(SRC / "shell" / "LocalNetwork.kt")
+ assert "ACCESS_LOCAL_NETWORK" in lan and "ACTION_APPLICATION_DETAILS_SETTINGS" in lan
+ from spa_source import STATIC
+ page = _read(STATIC / "group-page.js")
+ asked = page.split("platform.localNetwork.available && !lanAsked", 1)
+ assert len(asked) == 2 and "platform.localNetwork.request()" in asked[1][:200]
+ assert "lanAsked = true" in asked[1][:200]
+
+
def test_no_backup_carries_the_keys_away():
manifest = _read(APP / "src" / "main" / "AndroidManifest.xml")
assert 'android:allowBackup="false"' in manifest