Skip to content

Commit fd74e6f

Browse files
committed
Give the deleted switches something to turn on, except the one that should stay gone
The Settings screen had three switches removed for describing behaviour the app did not have: refresh every six hours, start on boot, and a quick-settings tile. Two come back with a mechanism behind them. The third does not, and that is a decision rather than work left undone. **Start on boot stays gone.** Android's always-on VPN starts the tunnel before applications run, keeps it running, and can block traffic while it is down. A BOOT_COMPLETED receiver starts later, can be killed, and cannot block anything — so shipping one would offer a worse version of a feature the Settings row already opens. The row that points at the system setting was already the better answer; it just did not say so. **Subscription refresh** is a periodic JobService, off unless the switch turns it on, and off by default because a refresh sends a bearer token to a dashboard and a client that started doing that on a schedule because it was installed would be deciding something that belongs to the user. Not WorkManager: it brings Room, SQLite and App Startup into an app that has none of them, to schedule one job, and the platform has had a scheduler with the same Doze semantics since five API levels below this client's minimum. What WorkManager buys — chained work, backoff, observability — this needs none of. The device case asks the scheduler what it is holding rather than trusting the call: `schedule` returns a result nobody reads, an interval the platform will not honour is silently adjusted, and a job that was never accepted looks exactly like one that has not fired yet. It also pins that the job is persisted, because an interval measured in hours whose schedule a restart cancelled would be a switch that works until the phone is turned off. **The tile** has no platform equivalent, which is why it is the one worth writing. It shows the node's name rather than on and off — the shade is a good place to see which Portal your traffic is going through — and it refuses to do two things: start a VPN without consent, because a service started anyway fails at establish() with the shade closing over it and nothing on screen; and guess a node, because picking a Portal for somebody from a tile is exactly the automatic selection the failover policy declines to do. With neither, it opens the app. Below API 34 the tile opens the app with the shade still over it. The documented call for that version is deprecated in 34, throws there, and lint refuses to let it stand even inside the version check that makes it safe — the check is not suppressible and these gates are not negotiable, so the old branch calls startActivity and the difference is written down rather than left to be rediscovered on an old device.
1 parent ec19c42 commit fd74e6f

16 files changed

Lines changed: 741 additions & 16 deletions

File tree

‎app/build.gradle.kts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,16 @@ kover {
263263
// so that the exemption is visible rather than achieved by
264264
// leaving the whole package out.
265265
"eu.nodepass.somewhere.apps.PackageManagerApps",
266+
// The other class in a gated package that a JVM test cannot
267+
// reach: a JobService the platform constructs and calls.
268+
// Everything it could get wrong that is not the platform's
269+
// own scheduling — whether the switch is on, what interval
270+
// a scheduler will honour — lives in RefreshPreferences,
271+
// which is inside the gate. Excluded by name so that the
272+
// exemption is visible rather than achieved by leaving the
273+
// whole package out.
274+
"eu.nodepass.somewhere.subscription.SubscriptionRefreshJob",
275+
"eu.nodepass.somewhere.subscription.SubscriptionRefreshJob\$Companion",
266276
"eu.nodepass.somewhere.BuildConfig",
267277
"*.R",
268278
"*.R$*",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.subscription
5+
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import androidx.test.platform.app.InstrumentationRegistry
8+
import eu.nodepass.somewhere.SomewhereApplication
9+
import org.junit.After
10+
import org.junit.Assert.assertEquals
11+
import org.junit.Assert.assertFalse
12+
import org.junit.Assert.assertTrue
13+
import org.junit.Test
14+
import org.junit.runner.RunWith
15+
16+
/**
17+
* That the platform actually took the schedule.
18+
*
19+
* The preference is unit-tested and needs no device. What needs one is the
20+
* other half: `JobScheduler.schedule` returns a result nobody reads, a job with
21+
* an interval the platform will not honour is silently adjusted, and a job that
22+
* was never accepted looks exactly like one that has not fired yet.
23+
*
24+
* So this asks the scheduler what it is holding, which is the only answer that
25+
* is not this app agreeing with itself.
26+
*/
27+
@RunWith(AndroidJUnit4::class)
28+
class SubscriptionRefreshScheduleTest {
29+
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
30+
private val application get() = context.applicationContext as SomewhereApplication
31+
32+
private var saved = RefreshPreferences.Settings()
33+
34+
@After
35+
fun restoreThePreference() {
36+
application.refreshPreferences.save(saved)
37+
SubscriptionRefreshJob.apply(context)
38+
}
39+
40+
@Test
41+
fun turningItOnPutsAJobInTheScheduler() {
42+
saved = application.refreshPreferences.load()
43+
application.refreshPreferences.save(RefreshPreferences.Settings(automatic = true, intervalHours = 6))
44+
SubscriptionRefreshJob.apply(context)
45+
assertTrue("the platform did not accept the schedule", SubscriptionRefreshJob.isScheduled(context))
46+
}
47+
48+
@Test
49+
fun turningItOffTakesTheJobAway() {
50+
// A schedule that outlived its preference would be a switch that says
51+
// off while a job runs every six hours — the exact shape of the
52+
// switches this screen deleted for describing behaviour that was not
53+
// there, facing the other way.
54+
saved = application.refreshPreferences.load()
55+
application.refreshPreferences.save(RefreshPreferences.Settings(automatic = true))
56+
SubscriptionRefreshJob.apply(context)
57+
assertTrue(SubscriptionRefreshJob.isScheduled(context))
58+
59+
application.refreshPreferences.save(RefreshPreferences.Settings(automatic = false))
60+
SubscriptionRefreshJob.apply(context)
61+
assertFalse("the schedule outlived the preference", SubscriptionRefreshJob.isScheduled(context))
62+
}
63+
64+
@Test
65+
fun schedulingTwiceLeavesOneJobRatherThanTwo() {
66+
// Same id, so a reschedule replaces. Without that, every visit to the
67+
// settings screen would add a job and the refresh would speed up the
68+
// more often somebody looked at the switch.
69+
saved = application.refreshPreferences.load()
70+
application.refreshPreferences.save(RefreshPreferences.Settings(automatic = true))
71+
SubscriptionRefreshJob.apply(context)
72+
SubscriptionRefreshJob.apply(context)
73+
74+
val scheduler = context.getSystemService(android.app.job.JobScheduler::class.java)!!
75+
val ours = scheduler.allPendingJobs.filter { it.id == SubscriptionRefreshJob.JOB_ID }
76+
assertEquals("one job, not one per call", 1, ours.size)
77+
}
78+
79+
@Test
80+
fun theScheduledJobSurvivesAReboot() {
81+
// The interval is measured in hours. A schedule a restart cancelled
82+
// would be a switch that works until the phone is turned off, which is
83+
// worse than no switch at all because nothing says it stopped.
84+
saved = application.refreshPreferences.load()
85+
application.refreshPreferences.save(RefreshPreferences.Settings(automatic = true))
86+
SubscriptionRefreshJob.apply(context)
87+
88+
val scheduler = context.getSystemService(android.app.job.JobScheduler::class.java)!!
89+
val job = scheduler.allPendingJobs.first { it.id == SubscriptionRefreshJob.JOB_ID }
90+
assertTrue("the job is not persisted", job.isPersisted)
91+
assertTrue("the job does not require a network", job.networkType != android.app.job.JobInfo.NETWORK_TYPE_NONE)
92+
}
93+
}

‎app/src/main/AndroidManifest.xml‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@
4141
the device sends with nothing on screen saying so. -->
4242
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
4343

44+
<!-- For one thing only: a periodic job that survives a reboot. The
45+
subscription refresh is measured in hours, and a schedule that a
46+
restart silently cancelled would be a switch that works until the
47+
phone is turned off. Nothing here starts anything at boot — Android's
48+
always-on VPN does that, before applications run, and does it better
49+
than a receiver could. -->
50+
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
51+
4452
<application
4553
android:name=".SomewhereApplication"
4654
android:allowBackup="false"
@@ -102,5 +110,28 @@
102110
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
103111
android:value="vpn" />
104112
</service>
113+
114+
<!-- The subscription refresh. Exported false and permission-guarded to
115+
the platform's own scheduler, which is the only thing that may
116+
start it: a job service anything could bind to is a network call
117+
anything could ask for, carrying a subscription token. -->
118+
<service
119+
android:name=".subscription.SubscriptionRefreshJob"
120+
android:exported="false"
121+
android:permission="android.permission.BIND_JOB_SERVICE" />
122+
123+
<!-- The quick-settings tile. Exported true because the system UI is a
124+
different process and has to bind it; guarded by BIND_QUICK_SETTINGS_TILE
125+
so that only the system may. -->
126+
<service
127+
android:name=".vpn.TunnelTileService"
128+
android:exported="true"
129+
android:label="@string/app_name"
130+
android:icon="@drawable/ic_notification"
131+
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
132+
<intent-filter>
133+
<action android:name="android.service.quicksettings.action.QS_TILE" />
134+
</intent-filter>
135+
</service>
105136
</application>
106137
</manifest>

‎app/src/main/kotlin/eu/nodepass/somewhere/MainActivity.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.lifecycle.lifecycleScope
2121
import eu.nodepass.somewhere.apps.AppsController
2222
import eu.nodepass.somewhere.protocol.url.NowhereUrl
2323
import eu.nodepass.somewhere.routing.RoutingController
24+
import eu.nodepass.somewhere.subscription.SubscriptionRefreshJob
2425
import eu.nodepass.somewhere.ui.SomewhereApp
2526
import eu.nodepass.somewhere.ui.theme.SomewhereTheme
2627
import eu.nodepass.somewhere.vpn.SomewhereVpnService
@@ -128,6 +129,8 @@ class MainActivity : ComponentActivity() {
128129
onToggleTunnel = ::toggleTunnel,
129130
onReconnect = { running?.let(::startTunnel) },
130131
onImportRules = { importRules.launch(ruleMimeTypes) },
132+
refresh = application.refreshPreferences,
133+
onRefreshScheduleChanged = { SubscriptionRefreshJob.apply(this@MainActivity) },
131134
)
132135
}
133136
}

‎app/src/main/kotlin/eu/nodepass/somewhere/SomewhereApplication.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import eu.nodepass.somewhere.data.SubscriptionStore
1212
import eu.nodepass.somewhere.nodes.NodeHealth
1313
import eu.nodepass.somewhere.routing.RoutingPreferences
1414
import eu.nodepass.somewhere.routing.RuleStore
15+
import eu.nodepass.somewhere.subscription.RefreshPreferences
1516
import eu.nodepass.somewhere.subscription.SubscriptionFetcher
1617
import kotlinx.coroutines.CoroutineScope
1718
import kotlinx.coroutines.Dispatchers
@@ -38,6 +39,7 @@ class SomewhereApplication : Application() {
3839
// Application-lifetime, and supervised so one failed fetch does not
3940
// take the scope down with it.
4041
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
42+
lastConnectedFile = File(filesDir, "nodes/last-connected.txt"),
4143
)
4244
}
4345

@@ -76,4 +78,14 @@ class SomewhereApplication : Application() {
7678
* measurement; a verdict is not.
7779
*/
7880
val nodeHealth: NodeHealth by lazy { NodeHealth() }
81+
82+
/**
83+
* Whether the subscription refreshes on its own.
84+
*
85+
* Its own file, read by a scheduled job that may run in a process this
86+
* screen is not part of.
87+
*/
88+
val refreshPreferences: RefreshPreferences by lazy {
89+
RefreshPreferences(File(filesDir, "subscription/refresh.txt"))
90+
}
7991
}

‎app/src/main/kotlin/eu/nodepass/somewhere/data/NodeRepository.kt‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.asStateFlow
1818
import kotlinx.coroutines.flow.update
1919
import kotlinx.coroutines.launch
2020
import kotlinx.coroutines.withContext
21+
import java.io.File
2122

2223
/**
2324
* What a probe found out about one node.
@@ -69,6 +70,20 @@ class NodeRepository(
6970
*/
7071
private val scope: CoroutineScope,
7172
private val clock: () -> Long = System::currentTimeMillis,
73+
/**
74+
* Where the last node a tunnel came up on is remembered.
75+
*
76+
* A file rather than memory because the reader is a quick-settings tile,
77+
* which runs in a process that may have been started for the tile alone and
78+
* has nothing else in it.
79+
*
80+
* It holds a node URL, which carries a shared key — the same material
81+
* `nodes.txt` beside it already holds, in the same directory, with the same
82+
* permissions. It is not a second exposure, and it is deliberately not a
83+
* second *copy*: what comes back is matched against the live list, so a
84+
* node the user deleted is gone from here the moment it is gone from there.
85+
*/
86+
private val lastConnectedFile: File? = null,
7287
) {
7388
private val _nodes = MutableStateFlow(store.load())
7489
val nodes: StateFlow<List<NodeStore.Entry>> = _nodes.asStateFlow()
@@ -89,6 +104,39 @@ class NodeRepository(
89104
/** Probe results, keyed by the node's rendered URL. */
90105
val probes: StateFlow<Map<String, ProbeResult>> = _probes.asStateFlow()
91106

107+
/**
108+
* The node a tunnel last came up on, if it is still in the list.
109+
*
110+
* Null when there is none, when the file cannot be read, or when the node
111+
* has since been deleted — and the last of the three is why this checks
112+
* rather than simply returning what it stored. A tile that connected to a
113+
* node the user removed would be the app disagreeing with its own list.
114+
*/
115+
fun lastConnected(): String? {
116+
val file = lastConnectedFile ?: return null
117+
val remembered =
118+
try {
119+
if (!file.exists()) return null
120+
file.readText().trim()
121+
} catch (_: java.io.IOException) {
122+
return null
123+
}
124+
if (remembered.isEmpty()) return null
125+
return _nodes.value.firstOrNull { it.line == remembered }?.line
126+
}
127+
128+
/** Remembers [line] as the node a tunnel came up on. Best effort. */
129+
fun recordConnected(line: String) {
130+
val file = lastConnectedFile ?: return
131+
try {
132+
file.parentFile?.mkdirs()
133+
file.writeText(line)
134+
} catch (_: java.io.IOException) {
135+
// A tile that offers the app instead of a node is a smaller failure
136+
// than a tunnel that refuses to come up over a preference.
137+
}
138+
}
139+
92140
fun refresh() {
93141
_nodes.value = store.load()
94142
_subscription.value = subscriptions.load()
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.subscription
5+
6+
import java.io.File
7+
import java.io.IOException
8+
9+
/**
10+
* Whether the subscription refreshes on its own, and how often.
11+
*
12+
* Its own file for the reason every other preference here has one: it is read
13+
* by a scheduled job in a process the screen that writes it may not be part of,
14+
* and a shared file would make one a reason to reparse the other.
15+
*
16+
* **Off by default.** A subscription fetch reaches a dashboard over the network
17+
* carrying a bearer token, and a client that started doing that on a schedule
18+
* because it was installed would be making a decision that is the user's. The
19+
* screen offers it; nothing turns it on.
20+
*/
21+
class RefreshPreferences(
22+
private val file: File,
23+
) {
24+
data class Settings(
25+
val automatic: Boolean = false,
26+
val intervalHours: Int = DEFAULT_INTERVAL_HOURS,
27+
) {
28+
/**
29+
* The interval, clamped to what a scheduler will actually honour.
30+
*
31+
* Read rather than validated on write, so that a file edited by hand —
32+
* or written by a version that allowed something this one does not —
33+
* produces a working schedule instead of a job that never runs.
34+
*/
35+
val effectiveIntervalHours: Int get() = intervalHours.coerceIn(MINIMUM_INTERVAL_HOURS, MAXIMUM_INTERVAL_HOURS)
36+
}
37+
38+
fun load(): Settings {
39+
if (!file.exists()) return Settings()
40+
return try {
41+
val fields = file.readText().trim().split(',')
42+
Settings(
43+
automatic = fields.getOrNull(0) == "on",
44+
intervalHours = fields.getOrNull(1)?.toIntOrNull() ?: DEFAULT_INTERVAL_HOURS,
45+
)
46+
} catch (_: IOException) {
47+
Settings()
48+
}
49+
}
50+
51+
fun save(settings: Settings): Boolean =
52+
try {
53+
file.parentFile?.mkdirs()
54+
val line = "${if (settings.automatic) "on" else "off"},${settings.intervalHours}\n"
55+
val temporary = File(file.parentFile, "${file.name}.tmp")
56+
temporary.writeText(line)
57+
if (!temporary.renameTo(file)) {
58+
file.writeText(line)
59+
temporary.delete()
60+
}
61+
true
62+
} catch (_: IOException) {
63+
false
64+
}
65+
66+
companion object {
67+
/**
68+
* Six hours, which is what the Settings screen used to claim before
69+
* anything did it.
70+
*
71+
* A subscription changes when a dashboard operator changes it, which is
72+
* rarely and unpredictably. More often costs battery and a request per
73+
* device per interval against somebody's server; less often means a
74+
* revoked node stays in the list for a working day.
75+
*/
76+
const val DEFAULT_INTERVAL_HOURS: Int = 6
77+
78+
/**
79+
* The platform will not schedule a periodic job more often than fifteen
80+
* minutes, and a client asking for less would be asking for a schedule
81+
* it does not get. An hour is the floor offered here.
82+
*/
83+
const val MINIMUM_INTERVAL_HOURS: Int = 1
84+
85+
/** A day. Beyond it, a manual refresh is the honest mechanism. */
86+
const val MAXIMUM_INTERVAL_HOURS: Int = 24
87+
}
88+
}

0 commit comments

Comments
 (0)