Builds older than the server's minimum ask to be updated
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" | ||
} | ||
} | ||