diff --git a/components/updater/api/src/main/java/com/flipperdevices/updater/api/UpdaterApi.kt b/components/updater/api/src/main/java/com/flipperdevices/updater/api/UpdaterApi.kt index 9e8bfe8b18..0fbf1ee935 100644 --- a/components/updater/api/src/main/java/com/flipperdevices/updater/api/UpdaterApi.kt +++ b/components/updater/api/src/main/java/com/flipperdevices/updater/api/UpdaterApi.kt @@ -12,6 +12,6 @@ interface UpdaterApi { fun onDeviceConnected(versionName: FirmwareVersion) - fun start(updateRequest: UpdateRequest) + suspend fun start(updateRequest: UpdateRequest) suspend fun cancel(silent: Boolean = false) } diff --git a/components/updater/api/src/main/java/com/flipperdevices/updater/model/UpdateRequest.kt b/components/updater/api/src/main/java/com/flipperdevices/updater/model/UpdateRequest.kt index aa1e495b03..8fe7c6d491 100644 --- a/components/updater/api/src/main/java/com/flipperdevices/updater/model/UpdateRequest.kt +++ b/components/updater/api/src/main/java/com/flipperdevices/updater/model/UpdateRequest.kt @@ -6,6 +6,7 @@ import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import kotlinx.serialization.Transient +import kotlinx.serialization.json.Json @Parcelize @Serializable @@ -15,7 +16,9 @@ data class UpdateRequest( val changelog: String?, val content: UpdateContent, val requestId: Long = System.currentTimeMillis() -) : Parcelable +) : Parcelable { + fun encode(): String = Json.encodeToString(serializer(), this) +} @Parcelize @Serializable diff --git a/components/updater/impl/build.gradle.kts b/components/updater/impl/build.gradle.kts index f2b4191b7f..0feedfc6be 100644 --- a/components/updater/impl/build.gradle.kts +++ b/components/updater/impl/build.gradle.kts @@ -26,6 +26,9 @@ dependencies { implementation(projects.components.analytics.metric.api) implementation(libs.lifecycle.runtime.ktx) + implementation(libs.work.ktx) + implementation(libs.ktx) + implementation(libs.kotlin.serialization.json) // Testing testImplementation(projects.components.core.test) @@ -40,5 +43,4 @@ dependencies { testImplementation(libs.ktor.negotiation) testImplementation(libs.ktor.serialization) testImplementation(libs.ktor.mock) - testImplementation(libs.kotlin.serialization.json) } diff --git a/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/api/UpdaterApiImpl.kt b/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/api/UpdaterApiImpl.kt index 30ff204ebe..307bd3431c 100644 --- a/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/api/UpdaterApiImpl.kt +++ b/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/api/UpdaterApiImpl.kt @@ -1,113 +1,88 @@ package com.flipperdevices.updater.impl.api import android.content.Context -import com.flipperdevices.bridge.rpc.api.FlipperStorageApi -import com.flipperdevices.bridge.service.api.provider.FlipperServiceProvider +import androidx.work.Data +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters import com.flipperdevices.core.di.AppGraph import com.flipperdevices.core.log.LogTagProvider import com.flipperdevices.core.log.info import com.flipperdevices.metric.api.MetricApi import com.flipperdevices.metric.api.events.complex.UpdateFlipperEnd -import com.flipperdevices.metric.api.events.complex.UpdateFlipperStart import com.flipperdevices.metric.api.events.complex.UpdateStatus import com.flipperdevices.updater.api.UpdaterApi -import com.flipperdevices.updater.impl.UpdaterTask -import com.flipperdevices.updater.impl.tasks.UploadToFlipperHelper -import com.flipperdevices.updater.impl.tasks.downloader.UpdateContentDownloader +import com.flipperdevices.updater.impl.service.UpdaterWorkManager import com.flipperdevices.updater.model.FirmwareChannel import com.flipperdevices.updater.model.FirmwareVersion import com.flipperdevices.updater.model.UpdateRequest import com.flipperdevices.updater.model.UpdatingState import com.flipperdevices.updater.model.UpdatingStateWithRequest -import com.flipperdevices.updater.subghz.helpers.SubGhzProvisioningHelper import com.squareup.anvil.annotations.ContributesBinding -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.withContext +import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.collectLatest @Singleton -@Suppress("LongParameterList") @ContributesBinding(AppGraph::class, UpdaterApi::class) class UpdaterApiImpl @Inject constructor( - private val serviceProvider: FlipperServiceProvider, - private val updateContentDownloader: MutableSet, - private val subGhzProvisioningHelper: SubGhzProvisioningHelper, - private val uploadToFlipperHelper: UploadToFlipperHelper, private val context: Context, - private val metricApi: MetricApi, - private val flipperStorageApi: FlipperStorageApi + private val updaterStateHolder: UpdaterStateHolder, + private val metricApi: MetricApi ) : UpdaterApi, LogTagProvider { - override val TAG = "UpdaterApi" + override val TAG = "UpdaterFlipperApi" - private val updatingState = MutableStateFlow( - UpdatingStateWithRequest(UpdatingState.NotStarted, request = null) - ) - - private var currentActiveTask: UpdaterTask? = null + private var workerId: UUID? = null private val isLaunched = AtomicBoolean(false) - override fun start(updateRequest: UpdateRequest) { + private val workManager by lazy { WorkManager.getInstance(context) } + + override suspend fun start(updateRequest: UpdateRequest) { info { "Request update with file $updateRequest" } if (!isLaunched.compareAndSet(false, true)) { info { "Update skipped, because we already in update" } return } - val localActiveTask = UpdaterTask( - serviceProvider, - context, - uploadToFlipperHelper, - subGhzProvisioningHelper, - updateContentDownloader, - flipperStorageApi - ) - currentActiveTask = localActiveTask - metricApi.reportComplexEvent( - UpdateFlipperStart( - updateFromVersion = updateRequest.updateFrom.version, - updateToVersion = updateRequest.updateTo.version, - updateId = updateRequest.requestId + val updateWorker = OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putString( + UpdaterWorkManager.UPDATE_REQUEST_KEY, + updateRequest.encode() + ) + .build() ) - ) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + workManager.enqueue(updateWorker) + workerId = updateWorker.id + info { "Update Worker id $workerId" } - localActiveTask.start(updateRequest) { - info { "Updater state update to $it" } - withContext(NonCancellable) { - updatingState.emit(UpdatingStateWithRequest(it, request = updateRequest)) - val endReason: UpdateStatus? = when (it) { - UpdatingState.FailedDownload -> UpdateStatus.FAILED_DOWNLOAD - UpdatingState.FailedPrepare -> UpdateStatus.FAILED_PREPARE - UpdatingState.FailedUpload -> UpdateStatus.FAILED_UPLOAD - else -> null - } - if (endReason != null) { - metricApi.reportComplexEvent( - UpdateFlipperEnd( - updateFrom = updateRequest.updateFrom.version, - updateTo = updateRequest.updateTo.version, - updateId = updateRequest.requestId, - updateStatus = endReason - ) - ) - } + workManager.getWorkInfoByIdFlow(updateWorker.id).collectLatest { workInfo -> + info { "Work Manager State: ${workInfo.id} ${workInfo.state}" } - if (it.isFinalState) { - currentActiveTask?.onStop() - currentActiveTask = null - isLaunched.set(false) - } + if (workInfo.state.isFinished) { + isLaunched.set(false) + workerId = null } } } override suspend fun cancel(silent: Boolean) { - val updateRequest = updatingState.value.request + info { "#cancel update with worker $workerId" } + + workManager.cancelWorkById(workerId ?: return) + isLaunched.set(false) + workerId = null + + val updateRequest = updaterStateHolder.getState().value.request if (updateRequest != null && !silent) { metricApi.reportComplexEvent( UpdateFlipperEnd( @@ -118,11 +93,10 @@ class UpdaterApiImpl @Inject constructor( ) ) } - currentActiveTask?.onStop() } override fun onDeviceConnected(versionName: FirmwareVersion) { - updatingState.update { + updaterStateHolder.update { if (it.state == UpdatingState.Rebooting) { if (it.request?.updateTo?.version == versionName.version) { UpdatingStateWithRequest(UpdatingState.Complete, request = it.request) @@ -137,10 +111,10 @@ class UpdaterApiImpl @Inject constructor( } } - override fun getState(): StateFlow = updatingState + override fun getState(): StateFlow = updaterStateHolder.getState() override fun resetState() { isLaunched.compareAndSet(true, false) - updatingState.update { + updaterStateHolder.update { if (it.state != UpdatingState.NotStarted && it.state.isFinalState) { UpdatingStateWithRequest(UpdatingState.NotStarted, request = null) } else { diff --git a/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/api/UpdaterStateHolder.kt b/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/api/UpdaterStateHolder.kt new file mode 100644 index 0000000000..28b405028b --- /dev/null +++ b/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/api/UpdaterStateHolder.kt @@ -0,0 +1,40 @@ +package com.flipperdevices.updater.impl.api + +import com.flipperdevices.core.di.AppGraph +import com.flipperdevices.updater.model.UpdatingState +import com.flipperdevices.updater.model.UpdatingStateWithRequest +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +interface UpdaterStateHolder { + fun getState(): StateFlow + + suspend fun updateState(state: UpdatingStateWithRequest) + + fun update(function: (UpdatingStateWithRequest) -> UpdatingStateWithRequest) +} + +@Singleton +@ContributesBinding(AppGraph::class, UpdaterStateHolder::class) +class UpdaterStateHolderImpl @Inject constructor() : UpdaterStateHolder { + private val updatingState = MutableStateFlow( + UpdatingStateWithRequest(UpdatingState.NotStarted, request = null) + ) + + override fun getState(): StateFlow { + return updatingState.asStateFlow() + } + + override suspend fun updateState(state: UpdatingStateWithRequest) { + updatingState.emit(state) + } + + override fun update(function: (UpdatingStateWithRequest) -> UpdatingStateWithRequest) { + updatingState.update(function) + } +} diff --git a/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/di/UpdaterComponent.kt b/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/di/UpdaterComponent.kt index 20082f34b8..5f2198c1e9 100644 --- a/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/di/UpdaterComponent.kt +++ b/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/di/UpdaterComponent.kt @@ -1,7 +1,10 @@ package com.flipperdevices.updater.impl.di import com.flipperdevices.core.di.AppGraph +import com.flipperdevices.updater.impl.service.UpdaterWorkManager import com.squareup.anvil.annotations.ContributesTo @ContributesTo(AppGraph::class) -interface UpdaterComponent +interface UpdaterComponent { + fun inject(updaterWorkManager: UpdaterWorkManager) +} diff --git a/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/service/UpdaterNotification.kt b/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/service/UpdaterNotification.kt new file mode 100644 index 0000000000..fe8dfcf1fe --- /dev/null +++ b/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/service/UpdaterNotification.kt @@ -0,0 +1,83 @@ +package com.flipperdevices.updater.impl.service + +import android.content.Context +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.ForegroundInfo +import androidx.work.WorkManager +import com.flipperdevices.updater.impl.R +import java.util.UUID +import com.flipperdevices.core.ui.res.R as DesignSystem +import com.flipperdevices.updater.model.UpdatingState + +private const val UPDATE_NOTIFICATION_CHANNEL = "update_notification_channel" + +object UpdaterNotification { + fun getForegroundInfo(id: UUID, context: Context): ForegroundInfo { + val intent = WorkManager.getInstance(context).createCancelPendingIntent(id) + + createNotificationChannel(context) + + val cancelButton = context.getString(R.string.update_notification_cancel) + val title = context.getString(R.string.update_notification_title) + val description = context.getString(R.string.update_notification_desc) + + val notification = NotificationCompat.Builder(context, UPDATE_NOTIFICATION_CHANNEL) + .setContentTitle(title) + .setTicker(title) + .setContentText(description) + .setSmallIcon(DesignSystem.drawable.ic_notification) + .setOngoing(true) + .addAction(android.R.drawable.ic_delete, cancelButton, intent) + .build() + + return ForegroundInfo(id.hashCode(), notification) + } + + private fun createNotificationChannel(context: Context) { + val notificationManager = NotificationManagerCompat.from(context) + + val flipperChannel = NotificationChannelCompat.Builder( + UPDATE_NOTIFICATION_CHANNEL, + NotificationManagerCompat.IMPORTANCE_DEFAULT + ) + .setName(context.getString(R.string.update_notification_channel_title)) + .setDescription(context.getString(R.string.update_notification_channel_desc)) + .build() + notificationManager.createNotificationChannel(flipperChannel) + } + + fun getForegroundStatusInfo(id: UUID, context: Context, state: UpdatingState): ForegroundInfo { + val intent = WorkManager.getInstance(context).createCancelPendingIntent(id) + createNotificationChannel(context) + + val cancelButton = context.getString(R.string.update_notification_cancel) + val title = context.getString(R.string.update_notification_title) + val description = when (state) { + UpdatingState.Complete -> "Compete" + is UpdatingState.DownloadingFromNetwork -> "DownloadingFromNetwork ${state.percent}" + UpdatingState.Failed -> "Failed" + UpdatingState.FailedCustomUpdate -> "FailedCustomUpdate" + UpdatingState.FailedDownload -> "FailedDownload" + UpdatingState.FailedInternalStorage -> "FailedInternalStorage" + UpdatingState.FailedOutdatedApp -> "FailedOutdatedApp" + UpdatingState.FailedPrepare -> "FailedPrepare" + UpdatingState.FailedSubGhzProvisioning -> "FailedPrepare" + UpdatingState.FailedUpload -> "FailedUpload" + UpdatingState.NotStarted -> "NotStarted" + UpdatingState.Rebooting -> "Rebooting" + UpdatingState.SubGhzProvisioning -> "SubGhzProvisioning" + is UpdatingState.UploadOnFlipper -> "UploadOnFlipper ${state.percent}" + } + + val notification = NotificationCompat.Builder(context, UPDATE_NOTIFICATION_CHANNEL) + .setContentTitle(title) + .setContentText(description) + .setSmallIcon(DesignSystem.drawable.ic_notification) + .addAction(android.R.drawable.ic_delete, cancelButton, intent) + .build() + + return ForegroundInfo(id.hashCode(), notification) + } +} diff --git a/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/service/UpdaterWorkManager.kt b/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/service/UpdaterWorkManager.kt new file mode 100644 index 0000000000..75c3f8a6e5 --- /dev/null +++ b/components/updater/impl/src/main/java/com/flipperdevices/updater/impl/service/UpdaterWorkManager.kt @@ -0,0 +1,152 @@ +package com.flipperdevices.updater.impl.service + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.flipperdevices.bridge.rpc.api.FlipperStorageApi +import com.flipperdevices.bridge.service.api.provider.FlipperServiceProvider +import com.flipperdevices.core.di.ComponentHolder +import com.flipperdevices.core.log.LogTagProvider +import com.flipperdevices.core.log.info +import com.flipperdevices.core.log.verbose +import com.flipperdevices.metric.api.MetricApi +import com.flipperdevices.metric.api.events.complex.UpdateFlipperEnd +import com.flipperdevices.metric.api.events.complex.UpdateFlipperStart +import com.flipperdevices.metric.api.events.complex.UpdateStatus +import com.flipperdevices.updater.impl.UpdaterTask +import com.flipperdevices.updater.impl.api.UpdaterStateHolder +import com.flipperdevices.updater.impl.di.UpdaterComponent +import com.flipperdevices.updater.impl.tasks.UploadToFlipperHelper +import com.flipperdevices.updater.impl.tasks.downloader.UpdateContentDownloader +import com.flipperdevices.updater.model.UpdateRequest +import com.flipperdevices.updater.model.UpdatingState +import com.flipperdevices.updater.model.UpdatingStateWithRequest +import com.flipperdevices.updater.subghz.helpers.SubGhzProvisioningHelper +import javax.inject.Inject +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json.Default.decodeFromString + +class UpdaterWorkManager( + private val context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params), LogTagProvider { + override val TAG: String = "UpdaterWorkManager" + + companion object { + const val UPDATE_REQUEST_KEY = "update_request_key" + } + + @Inject + lateinit var serviceProvider: FlipperServiceProvider + + @Inject + lateinit var uploadToFlipperHelper: UploadToFlipperHelper + + @Inject + lateinit var subGhzProvisioningHelper: SubGhzProvisioningHelper + + @Inject + lateinit var updateContentDownloader: MutableSet + + @Inject + lateinit var flipperStorageApi: FlipperStorageApi + + @Inject + lateinit var metricApi: MetricApi + + @Inject + lateinit var updaterStateHolder: UpdaterStateHolder + + init { + ComponentHolder.component().inject(this) + } + + private var currentActiveTask: UpdaterTask? = null + + override suspend fun doWork(): Result { + return try { + doWorkUnSafe() + Result.success() + } catch (exception: Exception) { + Result.failure() + } + } + + private fun doWorkUnSafe() { + val updateRequestJson = inputData.getString(UPDATE_REQUEST_KEY) + ?: throw Exception("Not exist data by $UPDATE_REQUEST_KEY") + verbose { "Update request in string: $updateRequestJson" } + + // setForeground(getForegroundInfo()) + + val updateRequest: UpdateRequest = decodeFromString(updateRequestJson) + + val localActiveTask = UpdaterTask( + serviceProvider, + context, + uploadToFlipperHelper, + subGhzProvisioningHelper, + updateContentDownloader, + flipperStorageApi + ) + currentActiveTask = localActiveTask + + metricApi.reportComplexEvent( + UpdateFlipperStart( + updateFromVersion = updateRequest.updateFrom.version, + updateToVersion = updateRequest.updateTo.version, + updateId = updateRequest.requestId + ) + ) + + localActiveTask.start(updateRequest) { updateState -> + info { "Updater state update to $updateState" } + // createForegroundInfoSafe(updateState) + + withContext(NonCancellable) { + updaterStateHolder.updateState(UpdatingStateWithRequest(updateState, request = updateRequest)) + + val endReason: UpdateStatus? = when (updateState) { + UpdatingState.FailedDownload -> UpdateStatus.FAILED_DOWNLOAD + UpdatingState.FailedPrepare -> UpdateStatus.FAILED_PREPARE + UpdatingState.FailedUpload -> UpdateStatus.FAILED_UPLOAD + else -> null + } + if (endReason != null) { + metricApi.reportComplexEvent( + UpdateFlipperEnd( + updateFrom = updateRequest.updateFrom.version, + updateTo = updateRequest.updateTo.version, + updateId = updateRequest.requestId, + updateStatus = endReason + ) + ) + } + + if (updateState.isFinalState) { + info { "Updater state is final, stopping" } + currentActiveTask?.onStop() + currentActiveTask = null + return@withContext + } + } + } + } +// +// private fun createForegroundInfoSafe(state: UpdatingState) { +// runCatching { +// val foregroundInfo = UpdaterNotification.getForegroundStatusInfo(this.id, context, state) +// setForeground(foregroundInfo) +// }.onFailure { +// error { "Error on setup foreground $it" } +// } +// } + + override suspend fun getForegroundInfo(): ForegroundInfo { + return UpdaterNotification.getForegroundInfo(this.id, context) + } +} diff --git a/components/updater/impl/src/main/res/values/strings.xml b/components/updater/impl/src/main/res/values/strings.xml new file mode 100644 index 0000000000..5c680ace7d --- /dev/null +++ b/components/updater/impl/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + Cancel + Flipper Update + Flipper Update Description + + + Flipper Update Channel + Flipper Update Channel Description + \ No newline at end of file