Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Background update flipper #748

Draft
wants to merge 5 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion components/updater/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -40,5 +43,4 @@ dependencies {
testImplementation(libs.ktor.negotiation)
testImplementation(libs.ktor.serialization)
testImplementation(libs.ktor.mock)
testImplementation(libs.kotlin.serialization.json)
}
Original file line number Diff line number Diff line change
@@ -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<UpdateContentDownloader>,
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<UpdaterWorkManager>()
.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(
Expand All @@ -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)
Expand All @@ -137,10 +111,10 @@ class UpdaterApiImpl @Inject constructor(
}
}

override fun getState(): StateFlow<UpdatingStateWithRequest> = updatingState
override fun getState(): StateFlow<UpdatingStateWithRequest> = 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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UpdatingStateWithRequest>

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<UpdatingStateWithRequest> {
return updatingState.asStateFlow()
}

override suspend fun updateState(state: UpdatingStateWithRequest) {
updatingState.emit(state)
}

override fun update(function: (UpdatingStateWithRequest) -> UpdatingStateWithRequest) {
updatingState.update(function)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}