~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1

Builds older than the server's minimum ask to be updated

Merged chadtmiller.com asked to merge into main from grain.social/grain-android:update-gate
app/src/main/java/social/grain/data/version/UpdateGate.ktnew+164−0
@@ -0,0 +1,164 @@
package social.grain.data.version
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.pm.PackageInfoCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.intOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
/** The Play listing, for when the server names no store of its own. */
const val DEFAULT_STORE_URL = "https://play.google.com/store/apps/details?id=social.grain"
/** This build is too old for the server and has to be updated to go on. */
data class UpdateRequired(val storeUrl: String)
/**
* The Android half of `/app-version.json`.
*
* [minimumVersionCode] is null when the server sets no floor, which is what a
* missing `android` key or a missing field both mean.
*/
data class AndroidVersionPolicy(
val minimumVersionCode: Int?,
val storeUrl: String?,
)
/**
* Parse `/app-version.json`, or null when it isn't the document the contract
* describes.
*
* Read by hand rather than through a `@Serializable` class, because the app's
* decoder coerces a mistyped field to its default: a `minimumVersionCode` of
* `"14"` or `14.5` would come out as "no minimum" and silently overwrite a
* floor that was cached from a good response. Here it is malformed, and a
* malformed answer leaves the cache alone.
*/
fun parseAndroidVersionPolicy(body: String, json: Json = Json): AndroidVersionPolicy? {
val root = runCatching { json.parseToJsonElement(body) }.getOrNull() as? JsonObject
?: return null
val android = when (val element = root["android"]) {
null, JsonNull -> return AndroidVersionPolicy(minimumVersionCode = null, storeUrl = null)
is JsonObject -> element
else -> return null
}
val minimum = when (val element = android["minimumVersionCode"]) {
null, JsonNull -> null
is JsonPrimitive -> if (element.isString) return null else element.intOrNull ?: return null
else -> return null
}
val storeUrl = (android["storeUrl"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.takeIf { it.isNotBlank() }
return AndroidVersionPolicy(minimumVersionCode = minimum, storeUrl = storeUrl)
}
/** Whether a build at [versionCode] is below the server's [minimum]. */
fun isBelowMinimum(versionCode: Long, minimum: Int?): Boolean =
minimum != null && versionCode < minimum
/**
* Whether this build is still allowed to talk to the appview.
*
* The server publishes a minimum `versionCode`, and a build below it gets a
* screen telling the reader to update instead of a run of failing requests.
* The last minimum fetched is kept on disk, so a blocked build stays blocked
* on a launch with no connection: the cached answer is what [required] starts
* from, before any request has gone out.
*
* A failed or malformed fetch changes nothing. With nothing cached that means
* the gate stays open, which is the right failure: an outage on the version
* file must never lock everyone out of a build that works.
*/
class UpdateGate(
private val prefs: SharedPreferences,
http: OkHttpClient,
private val json: Json,
baseUrl: String,
private val versionCode: Long,
) {
/** Short, so a slow server never holds anything up; the next foreground
* tries again. A derived client shares the app's connection pool. */
private val http = http.newBuilder().callTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS).build()
private val url = "${baseUrl.trimEnd('/')}/app-version.json"
private val _required = MutableStateFlow(decide(readCached()))
val required: StateFlow<UpdateRequired?> = _required.asStateFlow()
private val inFlight = Mutex()
/**
* Fetch the current policy and apply it.
*
* Launch and every return to the foreground both ask, and a resume can
* come round again while a request is still out, so a second caller skips
* rather than queueing a duplicate.
*/
suspend fun check() {
if (!inFlight.tryLock()) return
try {
val policy = fetch() ?: return
store(policy)
_required.value = decide(policy)
} finally {
inFlight.unlock()
}
}
private suspend fun fetch(): AndroidVersionPolicy? = withContext(Dispatchers.IO) {
runCatching {
http.newCall(Request.Builder().url(url).get().build()).execute().use { response ->
if (!response.isSuccessful) return@use null
parseAndroidVersionPolicy(response.body.string(), json)
}
}.getOrNull()
}
private fun decide(policy: AndroidVersionPolicy?): UpdateRequired? {
if (policy == null || !isBelowMinimum(versionCode, policy.minimumVersionCode)) return null
return UpdateRequired(policy.storeUrl ?: DEFAULT_STORE_URL)
}
private fun readCached(): AndroidVersionPolicy? {
if (!prefs.contains(KEY_MINIMUM)) return null
return AndroidVersionPolicy(
minimumVersionCode = prefs.getInt(KEY_MINIMUM, 0),
storeUrl = prefs.getString(KEY_STORE_URL, null),
)
}
private fun store(policy: AndroidVersionPolicy) {
prefs.edit().apply {
val minimum = policy.minimumVersionCode
if (minimum == null) remove(KEY_MINIMUM) else putInt(KEY_MINIMUM, minimum)
if (policy.storeUrl == null) remove(KEY_STORE_URL) else putString(KEY_STORE_URL, policy.storeUrl)
}.apply()
}
companion object {
const val PREFS_NAME = "grain_app_version"
private const val KEY_MINIMUM = "minimumVersionCode"
private const val KEY_STORE_URL = "storeUrl"
private const val TIMEOUT_SECONDS = 5L
/** The running build's `versionCode`, read from the installed package. */
fun installedVersionCode(context: Context): Long =
PackageInfoCompat.getLongVersionCode(
context.packageManager.getPackageInfo(context.packageName, 0),
)
}
}
app/src/main/java/social/grain/di/AppModule.kt+18−0
@@ -14,6 +14,7 @@ import okhttp3.OkHttpClient
import social.grain.data.api.XrpcClient
import social.grain.data.auth.AuthManager
import social.grain.data.auth.SecureStore
import social.grain.data.version.UpdateGate
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@@ -60,4 +61,21 @@ object AppModule {
@Provides
@Singleton
fun provideXrpcClient(auth: AuthManager): XrpcClient = auth.makeClient()
/** Asks the same host the XRPC client talks to, so the version file and the
* API it gates can never point at different servers. */
@Provides
@Singleton
fun provideUpdateGate(
@ApplicationContext context: Context,
http: OkHttpClient,
json: Json,
client: XrpcClient,
): UpdateGate = UpdateGate(
prefs = context.getSharedPreferences(UpdateGate.PREFS_NAME, Context.MODE_PRIVATE),
http = http,
json = json,
baseUrl = client.baseUrl,
versionCode = UpdateGate.installedVersionCode(context),
)
}
app/src/main/java/social/grain/feature/update/UpdateRequiredScreen.ktnew+114−0
@@ -0,0 +1,114 @@
package social.grain.feature.update
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import social.grain.ui.theme.SyneFamily
/**
* What a build below the server's minimum shows instead of the app.
*
* Drawn in place of everything else rather than as a dialog on top of it, so
* there is nothing underneath to fall back to, and back is swallowed: the only
* way forward is the store.
*/
@Composable
fun UpdateRequiredScreen(
storeUrl: String,
onUpdate: ((String) -> Unit)? = null,
) {
val context = LocalContext.current
BackHandler(enabled = true) { /* Nowhere to go back to. */ }
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.padding(28.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "grain",
fontFamily = SyneFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 44.sp,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(40.dp))
Text(
text = "Update Grain",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = "This version of Grain is no longer supported. Update to keep using it.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
Button(
onClick = { onUpdate?.invoke(storeUrl) ?: context.openStore(storeUrl) },
modifier = Modifier.fillMaxWidth(),
) {
Text("Update")
}
}
}
}
/**
* The Play Store app's own link for a Play listing URL, or null for any other
* store. `market://` opens the listing in the Play app directly rather than
* bouncing through a browser first.
*/
internal fun marketUriFor(storeUrl: String): Uri? {
val uri = storeUrl.toUri()
if (uri.host != "play.google.com" || uri.path != "/store/apps/details") return null
val id = uri.getQueryParameter("id")?.takeIf { it.isNotBlank() } ?: return null
return Uri.Builder().scheme("market").authority("details").appendQueryParameter("id", id).build()
}
/** Open the listing in the Play app when there is one, otherwise in whatever handles the URL. */
private fun Context.openStore(storeUrl: String) {
val candidates = listOfNotNull(marketUriFor(storeUrl), storeUrl.toUri())
for (uri in candidates) {
val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
startActivity(intent)
return
} catch (_: ActivityNotFoundException) {
// No Play app, or no browser: try the next way of getting there.
}
}
}
app/src/main/java/social/grain/MainActivity.kt+9−0
@@ -32,6 +32,7 @@ import kotlinx.coroutines.launch
import social.grain.data.local.Appearance
import social.grain.data.push.GrainMessagingService
import social.grain.feature.login.LoginScreen
import social.grain.feature.update.UpdateRequiredScreen
import social.grain.navigation.DeepLink
import social.grain.navigation.GrainNavHost
import social.grain.ui.components.ImageZoomHost
@@ -131,6 +132,14 @@ private fun GrainApp(viewModel: MainViewModel) {
val unseen by viewModel.unseenCount.collectAsStateWithLifecycle()
val deepLink by viewModel.pendingDeepLink.collectAsStateWithLifecycle()
val storyPresence by viewModel.storyPresence.collectAsStateWithLifecycle()
val updateRequired by viewModel.updateRequired.collectAsStateWithLifecycle()
// Ahead of everything, sign-in included: a build the server no longer
// supports would only fail its way through the screens below.
updateRequired?.let { required ->
UpdateRequiredScreen(storeUrl = required.storeUrl)
return
}
NotificationPermissionRequest(enabled = authState.isAuthenticated)
app/src/main/java/social/grain/MainViewModel.kt+14−0
@@ -26,6 +26,8 @@ import social.grain.data.local.ViewedStoryStore
import social.grain.feature.notifications.UnseenNotificationCount
import social.grain.data.push.PushManager
import social.grain.data.upload.GalleryUploadCenter
import social.grain.data.version.UpdateGate
import social.grain.data.version.UpdateRequired
import social.grain.navigation.DeepLink
import social.grain.ui.components.StoryPresence
import javax.inject.Inject
@@ -51,8 +53,16 @@ class MainViewModel @Inject constructor(
storyStatusCache: StoryStatusCache,
private val viewedStories: ViewedStoryStore,
appearanceStore: AppearanceStore,
private val updateGate: UpdateGate,
) : ViewModel() {
/**
* Set when this build is below the server's minimum, and the app shows
* nothing but the update screen. Starts from the last answer on disk, so a
* blocked build is blocked from the first frame, offline or not.
*/
val updateRequired: StateFlow<UpdateRequired?> = updateGate.required
val unseenCount: StateFlow<Int> = unseenNotifications.count
/** Light or dark, chosen in settings and applied around the whole app. */
@@ -177,7 +187,11 @@ class MainViewModel @Inject constructor(
}
}
/** Called on every resume, the first one included, which is what covers launch. */
fun onForeground() {
// Its own coroutine, so the version check neither waits on the token
// refresh below nor holds it up.
viewModelScope.launch { updateGate.check() }
viewModelScope.launch {
runCatching { auth.refreshIfNeeded() }
// Coming back is when the badge is most likely to be stale.
app/src/test/java/social/grain/MainViewModelTest.kt+8−0
@@ -20,6 +20,7 @@ import social.grain.data.local.AppearanceStore
import social.grain.data.push.PushManager
import social.grain.data.upload.GalleryDraftStore
import social.grain.data.upload.GalleryUploadCenter
import social.grain.data.version.UpdateGate
import social.grain.feature.notifications.UnseenNotificationCount
import social.grain.navigation.DeepLink
import social.grain.support.Fixtures
@@ -96,6 +97,13 @@ class MainViewModelTest {
storyStatusCache = harness.storyStatus,
viewedStories = harness.viewedStories,
appearanceStore = appearance,
updateGate = UpdateGate(
prefs = appContext.getSharedPreferences(UpdateGate.PREFS_NAME, android.content.Context.MODE_PRIVATE),
http = harness.server.client(),
json = testJson,
baseUrl = harness.client.baseUrl,
versionCode = 13,
),
)
}
app/src/test/java/social/grain/UpdateGateTest.ktnew+256−0
@@ -0,0 +1,256 @@
package social.grain
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import social.grain.data.version.AndroidVersionPolicy
import social.grain.data.version.DEFAULT_STORE_URL
import social.grain.data.version.UpdateGate
import social.grain.data.version.UpdateRequired
import social.grain.data.version.isBelowMinimum
import social.grain.data.version.parseAndroidVersionPolicy
import social.grain.feature.update.UpdateRequiredScreen
import social.grain.feature.update.marketUriFor
import social.grain.support.STUB_BASE_URL
import social.grain.support.StubServer
import social.grain.support.appContext
import social.grain.support.testJson
import java.io.IOException
/**
* The force-update gate.
*
* Its two failures are opposite and both bad: a gate that opens when it
* shouldn't sends an old build into an API it can't speak, and one that closes
* when it shouldn't locks everyone out over a missing file. So the tests are
* mostly about what a bad or absent answer does to a good cached one.
*/
@RunWith(AndroidJUnit4::class)
class UpdateGateTest {
@get:Rule
val composeRule = createComposeRule()
private lateinit var server: StubServer
private lateinit var prefs: SharedPreferences
@Before
fun setUp() {
server = StubServer()
prefs = appContext.getSharedPreferences(UpdateGate.PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().clear().commit()
}
@After
fun tearDown() {
prefs.edit().clear().commit()
server.shutdown()
}
private fun gate(
versionCode: Long = 13,
http: OkHttpClient = server.client(),
) = UpdateGate(prefs, http, testJson, STUB_BASE_URL, versionCode)
private fun versionFile(minimum: Int?, storeUrl: String? = PLAY_URL): String {
val fields = listOfNotNull(
minimum?.let { "\"minimumVersionCode\": $it" },
storeUrl?.let { "\"storeUrl\": \"$it\"" },
).joinToString(", ")
return """
{
"ios": { "minimumBuild": 0, "storeUrl": "https://apps.apple.com/app/id6747730230" },
"android": { $fields }
}
""".trimIndent()
}
// MARK: - Decision
@Test
fun onlyABuildStrictlyBelowTheMinimumIsBlocked() {
assertTrue(isBelowMinimum(versionCode = 12, minimum = 13))
assertFalse("The minimum itself is supported", isBelowMinimum(versionCode = 13, minimum = 13))
assertFalse(isBelowMinimum(versionCode = 14, minimum = 13))
assertFalse("No minimum blocks nothing", isBelowMinimum(versionCode = 1, minimum = null))
}
// MARK: - Parsing
@Test
fun theContractDocumentParses() {
assertEquals(
AndroidVersionPolicy(minimumVersionCode = 0, storeUrl = PLAY_URL),
parseAndroidVersionPolicy(versionFile(minimum = 0), testJson),
)
}
@Test
fun aMissingAndroidKeyOrMinimumMeansNoMinimum() {
assertEquals(
AndroidVersionPolicy(null, null),
parseAndroidVersionPolicy("""{"ios":{"minimumBuild":40}}""", testJson),
)
assertEquals(
AndroidVersionPolicy(null, PLAY_URL),
parseAndroidVersionPolicy(versionFile(minimum = null), testJson),
)
assertNull(
"A blank store URL is no store URL",
parseAndroidVersionPolicy("""{"android":{"minimumVersionCode":3,"storeUrl":" "}}""", testJson)!!.storeUrl,
)
}
@Test
fun anythingButTheContractIsMalformed() {
listOf(
"",
"not json",
"<html>502 Bad Gateway</html>",
"[]",
"""{"android":"14"}""",
"""{"android":{"minimumVersionCode":"14"}}""",
"""{"android":{"minimumVersionCode":14.5}}""",
"""{"android":{"minimumVersionCode":{"code":14}}}""",
).forEach { body ->
assertNull("Expected <$body> to be malformed", parseAndroidVersionPolicy(body, testJson))
}
}
// MARK: - Fetching
@Test
fun aBuildBelowTheFetchedMinimumIsSentToTheStore() = runBlocking<Unit> {
server.on("app-version.json", versionFile(minimum = 14))
val gate = gate(versionCode = 13)
assertNull("Nothing cached, so nothing blocks before the fetch", gate.required.value)
gate.check()
assertEquals(UpdateRequired(PLAY_URL), gate.required.value)
val call = server.callsTo("app-version.json").single()
assertEquals("$STUB_BASE_URL/app-version.json", call.url.toString())
assertEquals("GET", call.method)
}
@Test
fun aBuildAtOrAboveTheMinimumCarriesOn() = runBlocking<Unit> {
server.on("app-version.json", versionFile(minimum = 13))
val gate = gate(versionCode = 13)
gate.check()
assertNull(gate.required.value)
}
@Test
fun aMissingStoreUrlFallsBackToThePlayListing() = runBlocking<Unit> {
server.on("app-version.json", versionFile(minimum = 14, storeUrl = null))
val gate = gate(versionCode = 13)
gate.check()
assertEquals(UpdateRequired(DEFAULT_STORE_URL), gate.required.value)
}
@Test
fun nothingCachedAndNoAnswerMeansNoBlock() = runBlocking<Unit> {
server.on("app-version.json", "oops", code = 503)
gate().apply { check() }.also { assertNull(it.required.value) }
server.on("app-version.json", "<html>not the file</html>")
gate().apply { check() }.also { assertNull(it.required.value) }
gate(http = unreachable()).apply { check() }.also { assertNull(it.required.value) }
}
@Test
fun aBlockedBuildIsStillBlockedOnAnOfflineLaunch() = runBlocking<Unit> {
server.on("app-version.json", versionFile(minimum = 14))
gate().check()
// The next launch: a new gate over the same preferences, with no network.
val offline = gate(http = unreachable())
assertEquals(
"Blocked from the cache before any request goes out",
UpdateRequired(PLAY_URL),
offline.required.value,
)
offline.check()
assertEquals(UpdateRequired(PLAY_URL), offline.required.value)
}
@Test
fun aFailedOrMalformedFetchLeavesTheCachedMinimumAlone() = runBlocking<Unit> {
server.on("app-version.json", versionFile(minimum = 14))
gate().check()
server.on("app-version.json", "oops", code = 500)
gate().apply { check() }.also { assertEquals(UpdateRequired(PLAY_URL), it.required.value) }
server.on("app-version.json", """{"android":{"minimumVersionCode":"0"}}""")
gate().apply { check() }.also { assertEquals(UpdateRequired(PLAY_URL), it.required.value) }
}
@Test
fun aLoweredMinimumLiftsTheBlockAndForgetsIt() = runBlocking<Unit> {
server.on("app-version.json", versionFile(minimum = 14))
val gate = gate()
gate.check()
assertEquals(UpdateRequired(PLAY_URL), gate.required.value)
server.on("app-version.json", """{"ios":{"minimumBuild":0}}""")
gate.check()
assertNull(gate.required.value)
assertNull("The next launch starts open too", gate(http = unreachable()).required.value)
}
// MARK: - Screen
@Test
fun theScreenSaysWhyAndItsButtonOpensTheStore() {
var opened: String? = null
composeRule.setContent {
UpdateRequiredScreen(storeUrl = PLAY_URL, onUpdate = { opened = it })
}
composeRule.onNodeWithText("Update Grain").assertExists()
composeRule
.onNodeWithText("This version of Grain is no longer supported. Update to keep using it.")
.assertExists()
composeRule.onNodeWithText("Update").performClick()
assertEquals(PLAY_URL, opened)
}
@Test
fun aPlayListingOpensInThePlayApp() {
assertEquals("market://details?id=social.grain", marketUriFor(PLAY_URL).toString())
assertNull(marketUriFor("https://example.com/grain.apk"))
assertNull(marketUriFor("https://play.google.com/store/apps/details"))
}
private fun unreachable(): OkHttpClient = OkHttpClient.Builder()
.addInterceptor { throw IOException("offline") }
.build()
private companion object {
const val PLAY_URL = "https://play.google.com/store/apps/details?id=social.grain"
}
}