Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -7,6 +7,9 @@
|
|||||||
this permission never exercised.
|
this permission never exercised.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Only to answer "is this connection metered?" before the app downloads its own
|
||||||
|
update in the background. Normal permission, no prompt, no location. -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Four more permissions are NOT declared here and still reach the merged
|
Four more permissions are NOT declared here and still reach the merged
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentSender
|
import android.content.IntentSender
|
||||||
import android.content.pm.PackageInstaller
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.net.ConnectivityManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
@@ -63,6 +64,22 @@ object AppUpdate {
|
|||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
/** Where a download goes: app-private, so no storage permission is involved. */
|
/** Where a download goes: app-private, so no storage permission is involved. */
|
||||||
|
/**
|
||||||
|
* Whether this is a network to spend fifty-odd megabytes on without being asked.
|
||||||
|
*
|
||||||
|
* The update fetches itself in the background once one is found, and doing that
|
||||||
|
* over mobile data is a bill nobody agreed to. On a metered link the update is
|
||||||
|
* still FOUND and still nags — pressing Install downloads it then, which is a
|
||||||
|
* choice rather than a surprise.
|
||||||
|
*
|
||||||
|
* A missing ConnectivityManager reads as metered: the cautious answer is the one
|
||||||
|
* that costs nothing.
|
||||||
|
*/
|
||||||
|
fun onUnmeteredNetwork(context: Context): Boolean {
|
||||||
|
val manager = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
||||||
|
return !manager.isActiveNetworkMetered
|
||||||
|
}
|
||||||
|
|
||||||
fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
|
fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||||
import com.fabledsword.thoughtsync.ui.BoardSync
|
import com.fabledsword.thoughtsync.ui.BoardSync
|
||||||
|
import com.fabledsword.thoughtsync.ui.BoardUpdate
|
||||||
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
||||||
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
|
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
|
||||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||||
@@ -154,6 +155,7 @@ private fun App(
|
|||||||
var automatic by remember { mutableStateOf(settings.automatic) }
|
var automatic by remember { mutableStateOf(settings.automatic) }
|
||||||
|
|
||||||
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
|
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
|
||||||
|
AutomaticUpdate(linked = sync.state.linked, onCheck = update::checkInBackground)
|
||||||
|
|
||||||
val editing = board.state.editing
|
val editing = board.state.editing
|
||||||
val screen =
|
val screen =
|
||||||
@@ -219,6 +221,20 @@ private fun App(
|
|||||||
onSearch = board::search,
|
onSearch = board::search,
|
||||||
onCompose = board::compose,
|
onCompose = board::compose,
|
||||||
onToggleItem = board::toggleItem,
|
onToggleItem = board::toggleItem,
|
||||||
|
// Null unless there is genuinely something to say — the board is
|
||||||
|
// handed a decision, not a state to interpret.
|
||||||
|
update =
|
||||||
|
update.state.available
|
||||||
|
?.takeIf { update.state.nagging }
|
||||||
|
?.let {
|
||||||
|
BoardUpdate(
|
||||||
|
version = it.version,
|
||||||
|
ready = update.state.ready,
|
||||||
|
busy = update.state.busy,
|
||||||
|
onInstall = update::downloadAndInstall,
|
||||||
|
onDismiss = update::dismissNag,
|
||||||
|
)
|
||||||
|
},
|
||||||
onDismissError = board::dismissError,
|
onDismissError = board::dismissError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -271,6 +287,36 @@ private fun ReminderAlarms(core: ThoughtSync) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looking for an app update without being asked.
|
||||||
|
*
|
||||||
|
* Until this existed, `check()` had exactly one caller: a button on the sync screen.
|
||||||
|
* So a new build was found only by someone who went looking for one, and the operator
|
||||||
|
* had to remember to go looking — which is the same as not being told.
|
||||||
|
*
|
||||||
|
* On coming forward rather than on a timer: it is the moment the person is present,
|
||||||
|
* and the view model rate-limits so flicking between two apps is not a re-check.
|
||||||
|
* Unlinked devices are skipped entirely — updates come from a linked server, and
|
||||||
|
* there is nothing to ask.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AutomaticUpdate(
|
||||||
|
linked: Boolean,
|
||||||
|
onCheck: () -> Unit,
|
||||||
|
) {
|
||||||
|
var wanted by remember { mutableStateOf(false) }
|
||||||
|
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {})
|
||||||
|
|
||||||
|
LaunchedEffect(wanted, linked) {
|
||||||
|
if (!wanted || !linked) return@LaunchedEffect
|
||||||
|
// Consumed here, so this fires once per trip to the foreground however many
|
||||||
|
// times the effect restarts. There is no suspension point before the call, so
|
||||||
|
// the block completes before the recomposition that would cancel it.
|
||||||
|
wanted = false
|
||||||
|
onCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncing without being asked.
|
* Syncing without being asked.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ fun BoardScreen(
|
|||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
onCompose: () -> Unit,
|
onCompose: () -> Unit,
|
||||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||||
|
update: BoardUpdate?,
|
||||||
onDismissError: () -> Unit,
|
onDismissError: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -118,6 +119,18 @@ fun BoardScreen(
|
|||||||
ErrorBanner(message = message, onDismiss = sync.onDismissError)
|
ErrorBanner(message = message, onDismiss = sync.onDismissError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Below the failures and above the notes: an update is worth saying,
|
||||||
|
// and never worth saying before a note failed to save.
|
||||||
|
update?.let {
|
||||||
|
UpdateBanner(
|
||||||
|
version = it.version,
|
||||||
|
ready = it.ready,
|
||||||
|
busy = it.busy,
|
||||||
|
onInstall = it.onInstall,
|
||||||
|
onDismiss = it.onDismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Only where someone is already thinking about reminders. On the
|
// Only where someone is already thinking about reminders. On the
|
||||||
// main board it would nag people who have never set one.
|
// main board it would nag people who have never set one.
|
||||||
if (state.destination == Destination.Reminders) ReminderNotice()
|
if (state.destination == Destination.Reminders) ReminderNotice()
|
||||||
@@ -187,6 +200,26 @@ data class BoardSync(
|
|||||||
val onDismissError: () -> Unit,
|
val onDismissError: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The waiting app update, or null when there is nothing to say.
|
||||||
|
*
|
||||||
|
* A holder rather than five loose parameters, for the same reason [BoardSync] is one:
|
||||||
|
* `version` and a pair of booleans as positional arguments could be swapped with
|
||||||
|
* nothing to catch it.
|
||||||
|
*
|
||||||
|
* Null covers every reason there is nothing to show — unlinked, up to date, already
|
||||||
|
* dismissed for this sitting, mid-install — so the board never has to know which.
|
||||||
|
*/
|
||||||
|
data class BoardUpdate(
|
||||||
|
val version: String,
|
||||||
|
/** Already fetched, so Install is one tap rather than a wait. */
|
||||||
|
val ready: Boolean,
|
||||||
|
/** A check, fetch or install is in flight. */
|
||||||
|
val busy: Boolean,
|
||||||
|
val onInstall: () -> Unit,
|
||||||
|
val onDismiss: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A search field IS the top bar, following the phone convention rather than the
|
* A search field IS the top bar, following the phone convention rather than the
|
||||||
* desktop's title-plus-sidebar.
|
* desktop's title-plus-sidebar.
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -113,6 +122,62 @@ fun UpdateCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The nag: an update is waiting, said where someone will actually see it.
|
||||||
|
*
|
||||||
|
* Until this existed the only way to learn about a new build was to open the sync
|
||||||
|
* screen and press Check — so the updates that got installed were the ones somebody
|
||||||
|
* went looking for, and the rest were simply never found.
|
||||||
|
*
|
||||||
|
* Dismissible, but not permanently. "Later" clears it for this sitting; the next time
|
||||||
|
* the app comes forward the background check finds the same build and says so again.
|
||||||
|
* That is the difference between a reminder and a notice you can lose.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UpdateBanner(
|
||||||
|
version: String,
|
||||||
|
ready: Boolean,
|
||||||
|
busy: Boolean,
|
||||||
|
onInstall: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = noteTint("blue")
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(BANNER_RADIUS))
|
||||||
|
.background(tint.background(dark))
|
||||||
|
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_RADIUS))
|
||||||
|
.padding(start = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
// Two sentences for two states: fetched already, or waiting to be. The
|
||||||
|
// button is the same either way — the difference is how long it takes.
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
if (ready) R.string.update_banner_ready else R.string.update_banner_available,
|
||||||
|
version,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (busy) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(BANNER_SPINNER), strokeWidth = 2.dp)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.update_later)) }
|
||||||
|
TextButton(onClick = onInstall) { Text(stringResource(R.string.update_install)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val BANNER_RADIUS = 12.dp
|
||||||
|
private val BANNER_SPINNER = 18.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one line an unlinked device gets.
|
* The one line an unlinked device gets.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -24,10 +24,23 @@ data class UpdateState(
|
|||||||
val available: ClientUpdate? = null,
|
val available: ClientUpdate? = null,
|
||||||
/** A check completed and found nothing. Distinct from "not checked yet". */
|
/** A check completed and found nothing. Distinct from "not checked yet". */
|
||||||
val upToDate: Boolean = false,
|
val upToDate: Boolean = false,
|
||||||
|
/** The available build has been fetched and is sitting in the cache. */
|
||||||
|
val ready: Boolean = false,
|
||||||
|
val downloading: Boolean = false,
|
||||||
val working: Boolean = false,
|
val working: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
/** The banner has been waved away — until the app next comes forward. */
|
||||||
|
val nagDismissed: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val busy: Boolean get() = checking || working
|
val busy: Boolean get() = checking || downloading || working
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worth interrupting the board for.
|
||||||
|
*
|
||||||
|
* Not gated on [ready]: on a metered connection nothing is downloaded in advance,
|
||||||
|
* and an update nobody is told about is worse than one that costs a tap to fetch.
|
||||||
|
*/
|
||||||
|
val nagging: Boolean get() = available != null && !nagDismissed && !working
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,28 +66,82 @@ class UpdateViewModel(
|
|||||||
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
|
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/** Ask the linked server what it has. */
|
/** When the last check ran, so coming back to the app twice in a minute is one. */
|
||||||
fun check() {
|
private var lastCheckAt = 0L
|
||||||
|
|
||||||
|
/** Ask the linked server what it has. The Check button on the sync screen. */
|
||||||
|
fun check() = runCheck(fetch = false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The automatic path: look, fetch, then nag.
|
||||||
|
*
|
||||||
|
* Called when the app comes forward. Until this existed an update was only ever
|
||||||
|
* found by someone opening the sync screen and pressing a button — so the ones
|
||||||
|
* that mattered were the ones nobody went looking for.
|
||||||
|
*
|
||||||
|
* Skipped when a check is already in flight, when a build is already waiting, and
|
||||||
|
* when one ran recently: flicking between two apps is not a request to re-check.
|
||||||
|
*/
|
||||||
|
fun checkInBackground() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (state.busy || state.ready || now - lastCheckAt < CHECK_INTERVAL_MS) return
|
||||||
|
lastCheckAt = now
|
||||||
|
runCheck(fetch = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runCheck(fetch: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
state = state.copy(checking = true, error = null, upToDate = false)
|
state = state.copy(checking = true, error = null, upToDate = false)
|
||||||
state =
|
state =
|
||||||
try {
|
try {
|
||||||
val found = core.clientUpdate(state.installedVersion)
|
val found = core.clientUpdate(state.installedVersion)
|
||||||
state.copy(checking = false, available = found, upToDate = found == null)
|
state.copy(
|
||||||
|
checking = false,
|
||||||
|
available = found,
|
||||||
|
upToDate = found == null,
|
||||||
|
// A build that is still there is worth mentioning again. The
|
||||||
|
// dismissal was for that sitting, not for this version.
|
||||||
|
nagDismissed = if (found == null) state.nagDismissed else false,
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Broad by intent, as everywhere the core is called: it reports
|
// Broad by intent, as everywhere the core is called: it reports
|
||||||
// every failure as one error type carrying a message written to
|
// every failure as one error type carrying a message written to
|
||||||
// be read, and a failed check must not take the screen down.
|
// be read, and a failed check must not take the screen down.
|
||||||
state.copy(checking = false, error = e.message ?: FALLBACK)
|
state.copy(checking = false, error = e.message ?: FALLBACK)
|
||||||
}
|
}
|
||||||
|
// Fetched in advance so the nag is a one-tap install rather than the start
|
||||||
|
// of a wait. Not over mobile data: fifty-odd megabytes is a bill nobody
|
||||||
|
// agreed to, and on a metered link the Install button downloads instead.
|
||||||
|
if (fetch && state.available != null && AppUpdate.onUnmeteredNetwork(context)) {
|
||||||
|
download()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch the waiting build into the cache, leaving it for [install]. */
|
||||||
|
private suspend fun download() {
|
||||||
|
state = state.copy(downloading = true, error = null)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
core.downloadClientUpdate(AppUpdate.downloadTarget(context).absolutePath)
|
||||||
|
state.copy(downloading = false, ready = true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(downloading = false, error = e.message ?: FALLBACK_DOWNLOAD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop nagging until the app next comes forward and finds it again. */
|
||||||
|
fun dismissNag() {
|
||||||
|
state = state.copy(nagDismissed = true)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download the update and hand it to the system installer.
|
* Hand the update to the system installer, downloading first if it is not already
|
||||||
|
* in the cache.
|
||||||
*
|
*
|
||||||
* One action rather than two buttons: nobody wants a downloaded APK sitting
|
* Still one action from the outside. A downloaded APK is not a state anyone wants
|
||||||
* around as an intermediate state they have to think about.
|
* to think about, so whether the fetch already happened in the background is this
|
||||||
|
* class's problem rather than the person's.
|
||||||
*/
|
*/
|
||||||
fun downloadAndInstall() {
|
fun downloadAndInstall() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -83,7 +150,7 @@ class UpdateViewModel(
|
|||||||
val failure =
|
val failure =
|
||||||
try {
|
try {
|
||||||
val target = AppUpdate.downloadTarget(context)
|
val target = AppUpdate.downloadTarget(context)
|
||||||
core.downloadClientUpdate(target.absolutePath)
|
if (!state.ready) core.downloadClientUpdate(target.absolutePath)
|
||||||
// Off the main thread: this streams ~55 MiB into the session.
|
// Off the main thread: this streams ~55 MiB into the session.
|
||||||
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
|
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -126,3 +193,14 @@ class UpdateViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val FALLBACK = "The update couldn't be checked."
|
private const val FALLBACK = "The update couldn't be checked."
|
||||||
|
private const val FALLBACK_DOWNLOAD = "The update couldn't be downloaded."
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a background check stays good for.
|
||||||
|
*
|
||||||
|
* Long enough that switching to another app and back is not a re-check; short enough
|
||||||
|
* that a build published this morning is offered today. The same reasoning as sync's
|
||||||
|
* STALE_MINUTES, at a slower cadence — an app update is not urgent, it is just
|
||||||
|
* something that must not get lost.
|
||||||
|
*/
|
||||||
|
private const val CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000
|
||||||
|
|||||||
@@ -120,6 +120,9 @@
|
|||||||
<string name="update_current">You\'re on the newest build this server has.</string>
|
<string name="update_current">You\'re on the newest build this server has.</string>
|
||||||
<string name="update_check">Check for an update</string>
|
<string name="update_check">Check for an update</string>
|
||||||
<string name="update_install">Update</string>
|
<string name="update_install">Update</string>
|
||||||
|
<string name="update_banner_ready">Build %1$s is downloaded and ready.</string>
|
||||||
|
<string name="update_banner_available">Build %1$s is available.</string>
|
||||||
|
<string name="update_later">Later</string>
|
||||||
<string name="update_failed_title">The update didn\'t install</string>
|
<string name="update_failed_title">The update didn\'t install</string>
|
||||||
<string name="update_permission_title">Android needs your permission</string>
|
<string name="update_permission_title">Android needs your permission</string>
|
||||||
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
|
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user