Safer API and fixed syncing resume watching

This commit is contained in:
CranberrySoup 2023-10-31 21:36:15 +01:00
parent 4b7fc62237
commit 9e85359ad3
8 changed files with 257 additions and 201 deletions

View file

@ -44,7 +44,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
// used for active backup
val BackupApis
get() = listOf<BackupAPI<*>>(
get() = listOf<SafeBackupAPI>(
googleDriveApi, pcloudApi
)

View file

@ -7,9 +7,8 @@ import com.fasterxml.jackson.module.kotlin.readValue
import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.mvvm.debugException
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.LOG_KEY
import com.lagradost.cloudstream3.syncproviders.IBackupAPI.Companion.compareJson
import com.lagradost.cloudstream3.syncproviders.IBackupAPI.Companion.mergeBackup
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.BackupUtils
import com.lagradost.cloudstream3.utils.BackupUtils.getBackup
@ -22,7 +21,7 @@ import org.skyscreamer.jsonassert.JSONCompare
import org.skyscreamer.jsonassert.JSONCompareMode
import org.skyscreamer.jsonassert.JSONCompareResult
import kotlin.system.measureTimeMillis
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Duration.Companion.minutes
interface RemoteFile {
class Error(val message: String? = null, val throwable: Throwable? = null) : RemoteFile
@ -30,8 +29,83 @@ interface RemoteFile {
class Success(val remoteData: String) : RemoteFile
}
/**
* Safe wrapper for the backup api to be used outside the class without fear
* of causing crashes.
*/
interface SafeBackupAPI {
/**
* @return true if the service is ready for uploads and downloads.
* This includes a login check.
*/
suspend fun getIsReady(): Boolean
suspend fun scheduleDownload(runNow: Boolean = false)
fun getIsLoggedIn(): Boolean
fun scheduleUpload()
fun scheduleUpload(changedKey: String, isSettings: Boolean)
/**
* Warns the service that an upload is incoming. Used to prevent simultaneous download and upload.
*/
fun setIsUploadingSoon()
}
/**
* Easy interface to implement remote sync by only implementing the download and upload part of the service.
* @see BackupAPI for how the methods get used
*/
interface IBackupAPI<LOGIN_DATA> {
/**
* Gets the user login info for uploading and downloading the backup.
* If null no backup or download will be run.
*/
suspend fun getLoginData(): LOGIN_DATA?
/**
* Additional check if the backup operation should be run.
* Return false here to deny any backup work.
*/
suspend fun isReady(): Boolean = true
/**
* Get the backup file as a string from the remote storage.
* @see RemoteFile.Success
* @see RemoteFile.Error
* @see RemoteFile.NotFound
*/
suspend fun getRemoteFile(context: Context, loginData: LOGIN_DATA): RemoteFile
suspend fun uploadFile(
context: Context,
backupJson: String,
loginData: LOGIN_DATA
)
}
/**
* Helper class for the IBackupAPI which implements a scheduler, logging and checks.
* This makes the individual backup service implementations easier and more lightweight.
*/
abstract class BackupAPI<LOGIN_DATA>(defIndex: Int) : IBackupAPI<LOGIN_DATA>,
AccountManager(defIndex) {
AccountManager(defIndex), SafeBackupAPI {
data class JSONComparison(
val failed: Boolean,
val result: JSONCompareResult?
)
data class PreferencesSchedulerData<T>(
val syncPrefs: SharedPreferences,
val storeKey: String,
val oldValue: T,
val newValue: T,
val source: BackupUtils.RestoreSource
)
data class SharedPreferencesWithListener(
val self: SharedPreferences,
val scheduler: Scheduler<PreferencesSchedulerData<*>>
)
companion object {
const val LOG_KEY = "BACKUP"
@ -39,8 +113,118 @@ abstract class BackupAPI<LOGIN_DATA>(defIndex: Int) : IBackupAPI<LOGIN_DATA>,
// cloud project per user so there is no way to hit quota. Later we should implement
// some kind of adaptive throttling which will increase decrease throttle time based
// on factors like: live devices, quota limits, etc
val UPLOAD_THROTTLE = 30.seconds
val DOWNLOAD_THROTTLE = 120.seconds
val UPLOAD_THROTTLE = 5.minutes
val DOWNLOAD_THROTTLE = 5.minutes
const val SYNC_HISTORY_PREFIX = "_hs/"
fun SharedPreferences.logHistoryChanged(
path: String,
source: BackupUtils.RestoreSource
) {
edit().putLong("${source.syncPrefix}$path", System.currentTimeMillis()).apply()
}
fun compareJson(old: String, new: String): JSONComparison {
var result: JSONCompareResult?
val executionTime = measureTimeMillis {
result = try {
JSONCompare.compareJSON(old, new, JSONCompareMode.NON_EXTENSIBLE)
} catch (e: Exception) {
null
}
}
val failed = result?.failed() ?: true
Log.d(
LOG_KEY,
"JSON comparison took $executionTime ms, compareFailed=$failed, result=$result"
)
return JSONComparison(failed, result)
}
private fun getSyncKeys(data: BackupUtils.BackupFile) =
data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) }
/**
* Merges the backup data with the app data.
* @param overwrite if true it overwrites all data same as restoring from a backup.
* if false it only updates outdated keys. Should be true on first initialization.
*/
private fun mergeBackup(context: Context, incomingData: String, overwrite: Boolean) {
val newData = DataStore.mapper.readValue<BackupUtils.BackupFile>(incomingData)
if (overwrite) {
Log.d(LOG_KEY, "overwriting data")
context.restore(newData)
return
}
val keysToUpdate = getKeysToUpdate(context.getBackup(), newData)
if (keysToUpdate.isEmpty()) {
Log.d(LOG_KEY, "remote data is up to date, sync not needed")
return
}
Log.d(LOG_KEY, incomingData)
context.restore(newData, keysToUpdate)
}
private fun getKeysToUpdate(
currentData: BackupUtils.BackupFile,
newData: BackupUtils.BackupFile
): Set<String> {
val currentSync = getSyncKeys(currentData)
val newSync = getSyncKeys(newData)
val changedKeys = newSync.filter {
val localTimestamp = currentSync[it.key] ?: 0L
it.value > localTimestamp
}.keys
val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) }
val missingKeys = getAllMissingKeys(currentData, newData)
return (missingKeys + onlyLocalKeys + changedKeys).toSet()
}
private fun getAllMissingKeys(
old: BackupUtils.BackupFile,
new: BackupUtils.BackupFile
): List<String> = BackupUtils.RestoreSource
.values()
.filter { it != BackupUtils.RestoreSource.SYNC }
.fold(mutableListOf()) { acc, source ->
acc.addAll(getMissingKeysPrefixed(source, old, new))
acc
}
private fun getMissingKeysPrefixed(
restoreSource: BackupUtils.RestoreSource,
old: BackupUtils.BackupFile,
new: BackupUtils.BackupFile
): List<String> {
val oldSource = old.getData(restoreSource)
val newSource = new.getData(restoreSource)
val prefixToMatch = restoreSource.syncPrefix
return listOf(
*getMissing(oldSource._Bool, newSource._Bool),
*getMissing(oldSource._Long, newSource._Long),
*getMissing(oldSource._Float, newSource._Float),
*getMissing(oldSource._Int, newSource._Int),
*getMissing(oldSource._String, newSource._String),
*getMissing(oldSource._StringSet, newSource._StringSet),
).map {
prefixToMatch + it
}
}
private fun getMissing(old: Map<String, *>?, new: Map<String, *>?): Array<String> =
(new.orEmpty().keys - old.orEmpty().keys).toTypedArray()
}
/**
@ -77,7 +261,7 @@ abstract class BackupAPI<LOGIN_DATA>(defIndex: Int) : IBackupAPI<LOGIN_DATA>,
}
}
var willUploadSoon: Boolean? = null
private var willUploadSoon: Boolean? = null
private var uploadJob: Job? = null
private fun shouldUploadBackup(): Boolean {
@ -87,19 +271,23 @@ abstract class BackupAPI<LOGIN_DATA>(defIndex: Int) : IBackupAPI<LOGIN_DATA>,
return compareJson(lastBackupJson ?: "", newBackup).failed
}
fun scheduleUpload() {
if (!shouldUploadBackup()) {
willUploadSoon = false
Log.d(LOG_KEY, "${this.name}: upload not required, data is same")
return
}
override fun scheduleUpload() {
normalSafeApiCall {
if (!shouldUploadBackup()) {
willUploadSoon = false
Log.d(LOG_KEY, "${this.name}: upload not required, data is same")
return@normalSafeApiCall
}
upload()
upload()
}
}
// changedKey and isSettings is currently unused, might be useful for more efficient update checker.
fun scheduleUpload(changedKey: String, isSettings: Boolean) {
scheduleUpload()
override fun scheduleUpload(changedKey: String, isSettings: Boolean) {
normalSafeApiCall {
scheduleUpload()
}
}
private fun upload() {
@ -170,7 +358,10 @@ abstract class BackupAPI<LOGIN_DATA>(defIndex: Int) : IBackupAPI<LOGIN_DATA>,
}
is RemoteFile.Error -> {
Log.d(LOG_KEY, "${this.name}: getRemoteFile failed with message: ${remoteFile.message}.")
Log.d(
LOG_KEY,
"${this.name}: getRemoteFile failed with message: ${remoteFile.message}."
)
remoteFile.throwable?.let { error -> logError(error) }
null
}
@ -195,162 +386,26 @@ abstract class BackupAPI<LOGIN_DATA>(defIndex: Int) : IBackupAPI<LOGIN_DATA>,
lastBackupJson = remoteData
mergeBackup(context, remoteData, overwrite)
}
}
interface IBackupAPI<LOGIN_DATA> {
data class JSONComparison(
val failed: Boolean,
val result: JSONCompareResult?
)
data class PreferencesSchedulerData<T>(
val syncPrefs: SharedPreferences,
val storeKey: String,
val oldValue: T,
val newValue: T,
val source: BackupUtils.RestoreSource
)
data class SharedPreferencesWithListener(
val self: SharedPreferences,
val scheduler: Scheduler<PreferencesSchedulerData<*>>
)
/**
* Gets the user login info for uploading and downloading the backup.
* If null no backup or download will be run.
*/
suspend fun getLoginData(): LOGIN_DATA?
/**
* Additional check if the backup operation should be run.
* Return false here to deny any backup work.
*/
suspend fun isReady(): Boolean = true
/**
* Get the backup file as a string from the remote storage.
* @see RemoteFile.Success
* @see RemoteFile.Error
* @see RemoteFile.NotFound
*/
suspend fun getRemoteFile(context: Context, loginData: LOGIN_DATA): RemoteFile
suspend fun uploadFile(
context: Context,
backupJson: String,
loginData: LOGIN_DATA
)
companion object {
const val SYNC_HISTORY_PREFIX = "_hs/"
fun SharedPreferences.logHistoryChanged(path: String, source: BackupUtils.RestoreSource) {
edit().putLong("${source.syncPrefix}$path", System.currentTimeMillis())
.apply()
}
fun compareJson(old: String, new: String): JSONComparison {
var result: JSONCompareResult?
val executionTime = measureTimeMillis {
result = try {
JSONCompare.compareJSON(old, new, JSONCompareMode.NON_EXTENSIBLE)
} catch (e: Exception) {
null
}
}
val failed = result?.failed() ?: true
Log.d(
LOG_KEY,
"JSON comparison took $executionTime ms, compareFailed=$failed, result=$result"
)
return JSONComparison(failed, result)
}
private fun getSyncKeys(data: BackupUtils.BackupFile) =
data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) }
/**
* Merges the backup data with the app data.
* @param overwrite if true it overwrites all data same as restoring from a backup.
* if false it only updates outdated keys. Should be true on first initialization.
*/
fun mergeBackup(context: Context, incomingData: String, overwrite: Boolean) {
val newData = DataStore.mapper.readValue<BackupUtils.BackupFile>(incomingData)
if (overwrite) {
Log.d(LOG_KEY, "overwriting data")
context.restore(newData)
return
}
val keysToUpdate = getKeysToUpdate(context.getBackup(), newData)
if (keysToUpdate.isEmpty()) {
Log.d(LOG_KEY, "remote data is up to date, sync not needed")
return
}
Log.d(LOG_KEY, incomingData)
context.restore(newData, keysToUpdate)
}
private fun getKeysToUpdate(
currentData: BackupUtils.BackupFile,
newData: BackupUtils.BackupFile
): Set<String> {
val currentSync = getSyncKeys(currentData)
val newSync = getSyncKeys(newData)
val changedKeys = newSync.filter {
val localTimestamp = currentSync[it.key] ?: 0L
it.value > localTimestamp
}.keys
val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) }
val missingKeys = getAllMissingKeys(currentData, newData)
return (missingKeys + onlyLocalKeys + changedKeys).toSet()
}
private fun getAllMissingKeys(
old: BackupUtils.BackupFile,
new: BackupUtils.BackupFile
): List<String> = BackupUtils.RestoreSource
.values()
.filter { it != BackupUtils.RestoreSource.SYNC }
.fold(mutableListOf()) { acc, source ->
acc.addAll(getMissingKeysPrefixed(source, old, new))
acc
}
private fun getMissingKeysPrefixed(
restoreSource: BackupUtils.RestoreSource,
old: BackupUtils.BackupFile,
new: BackupUtils.BackupFile
): List<String> {
val oldSource = old.getData(restoreSource)
val newSource = new.getData(restoreSource)
val prefixToMatch = restoreSource.syncPrefix
return listOf(
*getMissing(oldSource._Bool, newSource._Bool),
*getMissing(oldSource._Long, newSource._Long),
*getMissing(oldSource._Float, newSource._Float),
*getMissing(oldSource._Int, newSource._Int),
*getMissing(oldSource._String, newSource._String),
*getMissing(oldSource._StringSet, newSource._StringSet),
).map {
prefixToMatch + it
}
}
private fun getMissing(old: Map<String, *>?, new: Map<String, *>?): Array<String> =
(new.orEmpty().keys - old.orEmpty().keys)
.toTypedArray()
// ------ SafeBackupAPI wrappers ------
override suspend fun getIsReady(): Boolean {
return suspendSafeApiCall {
isReady()
} ?: false
}
}
override fun getIsLoggedIn(): Boolean {
return normalSafeApiCall { loginInfo() } != null
}
override fun setIsUploadingSoon() {
willUploadSoon = true
}
override suspend fun scheduleDownload(runNow: Boolean) {
suspendSafeApiCall {
scheduleDownload(runNow, false)
}
}
}

View file

@ -85,9 +85,9 @@ class GoogleDriveApi(index: Int) :
accountId, key.value, value
)
private fun clearValue(key: K) = removeKey(accountId, key.value)
private fun clearValue(key: K) = removeKey(accountId, key.value)
private inline fun <reified T : Any> getValue(key: K) = getKey<T>(
private inline fun <reified T : Any> getValue(key: K) = getKey<T>(
accountId, key.value
)

View file

@ -13,6 +13,7 @@ import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.AccountManagmentBinding
import com.lagradost.cloudstream3.databinding.AccountSwitchBinding
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.googleDriveApi
@ -150,7 +151,7 @@ class SettingsAccount : PreferenceFragmentCompat() {
title =
getString(R.string.login_format).format(api.name, getString(R.string.account))
setOnPreferenceClickListener {
val info = api.loginInfo()
val info = normalSafeApiCall { api.loginInfo() }
if (info != null) {
showLoginInfo(activity, api, info)
} else {

View file

@ -24,9 +24,9 @@ import com.lagradost.cloudstream3.databinding.MainSettingsBinding
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.BackupApis
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.ui.home.HomeFragment
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.setImage
@ -62,7 +62,8 @@ class SettingsFragment : Fragment() {
fun Fragment?.setUpToolbar(title: String) {
if (this == null) return
val settingsToolbar = view?.findViewById<MaterialToolbar>(R.id.settings_toolbar) ?: return
val settingsToolbar =
view?.findViewById<MaterialToolbar>(R.id.settings_toolbar) ?: return
settingsToolbar.apply {
setTitle(title)
@ -76,7 +77,8 @@ class SettingsFragment : Fragment() {
fun Fragment?.setUpToolbar(@StringRes title: Int) {
if (this == null) return
val settingsToolbar = view?.findViewById<MaterialToolbar>(R.id.settings_toolbar) ?: return
val settingsToolbar =
view?.findViewById<MaterialToolbar>(R.id.settings_toolbar) ?: return
settingsToolbar.apply {
setTitle(title)
@ -213,7 +215,7 @@ class SettingsFragment : Fragment() {
// Only show the button if the api does not require login, requires login, but the user is logged in
forceSyncDataBtt.isVisible = BackupApis.any { api ->
api !is AuthAPI || api.loginInfo() != null
api.getIsLoggedIn()
}
forceSyncDataBtt.setOnClickListener {
@ -223,7 +225,8 @@ class SettingsFragment : Fragment() {
showToast(activity, txt(R.string.syncing_data), Toast.LENGTH_SHORT)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
forceSyncDataBtt.tooltipText = txt(R.string.sync_data).asString(forceSyncDataBtt.context)
forceSyncDataBtt.tooltipText =
txt(R.string.sync_data).asString(forceSyncDataBtt.context)
}
// Default focus on TV

View file

@ -54,7 +54,7 @@ object BackupUtils {
DATA, SETTINGS, SYNC;
val prefix = "$name/"
val syncPrefix = "${IBackupAPI.SYNC_HISTORY_PREFIX}$prefix"
val syncPrefix = "${BackupAPI.SYNC_HISTORY_PREFIX}$prefix"
}
/**
@ -80,7 +80,6 @@ object BackupUtils {
OPEN_SUBTITLES_USER_KEY,
"nginx_user", // Nginx user key
DOWNLOAD_HEADER_CACHE,
DOWNLOAD_EPISODE_CACHE
)
@ -329,7 +328,7 @@ object BackupUtils {
var prefixToRemove = prefixToMatch
if (restoreSource == RestoreSource.SYNC) {
prefixToMatch = IBackupAPI.SYNC_HISTORY_PREFIX
prefixToMatch = BackupAPI.SYNC_HISTORY_PREFIX
prefixToRemove = ""
}

View file

@ -10,9 +10,9 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.getKeyClass
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.BackupAPI
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
import com.lagradost.cloudstream3.syncproviders.IBackupAPI
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
const val DOWNLOAD_HEADER_CACHE = "download_header_cache"
@ -149,7 +149,7 @@ object DataStore {
ioSafe {
backupScheduler.work(
IBackupAPI.PreferencesSchedulerData(
BackupAPI.PreferencesSchedulerData(
getSyncPrefs(),
path,
oldValueExists,
@ -184,7 +184,7 @@ object DataStore {
ioSafe {
backupScheduler.work(
IBackupAPI.PreferencesSchedulerData(
BackupAPI.PreferencesSchedulerData(
getSyncPrefs(),
path,
oldValue,

View file

@ -6,14 +6,12 @@ import android.os.Looper
import android.util.Log
import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.BackupAPI
import com.lagradost.cloudstream3.syncproviders.IBackupAPI
import com.lagradost.cloudstream3.syncproviders.IBackupAPI.Companion.logHistoryChanged
import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.logHistoryChanged
import com.lagradost.cloudstream3.ui.home.HOME_BOOKMARK_VALUE_LIST
import com.lagradost.cloudstream3.ui.player.PLAYBACK_SPEED_KEY
import com.lagradost.cloudstream3.ui.player.RESIZE_MODE_KEY
import com.lagradost.cloudstream3.utils.BackupUtils.nonTransferableKeys
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import kotlinx.coroutines.runBlocking
class Scheduler<INPUT>(
private val throttleTimeMs: Long,
@ -40,7 +38,7 @@ class Scheduler<INPUT>(
Regex("""^\d+/$RESULT_DUB/"""),
)
fun createBackupScheduler() = Scheduler<IBackupAPI.PreferencesSchedulerData<*>>(
fun createBackupScheduler() = Scheduler<BackupAPI.PreferencesSchedulerData<*>>(
BackupAPI.UPLOAD_THROTTLE.inWholeMilliseconds,
onWork = { input ->
AccountManager.BackupApis.forEach { api ->
@ -52,13 +50,13 @@ class Scheduler<INPUT>(
},
beforeWork = { _ ->
AccountManager.BackupApis.filter { api ->
api.isReady()
api.getIsReady()
}.forEach {
it.willUploadSoon = true
it.setIsUploadingSoon()
}
},
canWork = { input ->
val hasSomeActiveManagers = AccountManager.BackupApis.any { it.isReady() }
val hasSomeActiveManagers = AccountManager.BackupApis.any { it.getIsReady() }
if (!hasSomeActiveManagers) {
return@Scheduler false
}
@ -97,14 +95,14 @@ class Scheduler<INPUT>(
fun SharedPreferences.attachBackupListener(
source: BackupUtils.RestoreSource = BackupUtils.RestoreSource.SETTINGS,
syncPrefs: SharedPreferences
): IBackupAPI.SharedPreferencesWithListener {
): BackupAPI.SharedPreferencesWithListener {
val scheduler = createBackupScheduler()
var lastValue = all
registerOnSharedPreferenceChangeListener { sharedPreferences, storeKey ->
ioSafe {
scheduler.work(
IBackupAPI.PreferencesSchedulerData(
BackupAPI.PreferencesSchedulerData(
syncPrefs,
storeKey,
lastValue[storeKey],
@ -116,10 +114,10 @@ class Scheduler<INPUT>(
lastValue = sharedPreferences.all
}
return IBackupAPI.SharedPreferencesWithListener(this, scheduler)
return BackupAPI.SharedPreferencesWithListener(this, scheduler)
}
fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences): IBackupAPI.SharedPreferencesWithListener {
fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences): BackupAPI.SharedPreferencesWithListener {
return attachBackupListener(BackupUtils.RestoreSource.SETTINGS, syncPrefs)
}
}
@ -172,7 +170,7 @@ class Scheduler<INPUT>(
runnable = Runnable {
Log.d(BackupAPI.LOG_KEY, "[$id] schedule success")
runBlocking {
ioSafe {
onWork(input)
}
}.also { run ->