Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ android {
applicationId = "com.iboalali.basicrootchecker"
minSdk = 23
targetSdk = 36
versionCode = 42
versionCode = 43
versionName = "v2.0vc$versionCode"
@Suppress("UnstableApiUsage")
androidResources.localeFilters += listOf("en", "ar", "de")
Expand All @@ -30,6 +30,17 @@ android {
}
}

flavorDimensions += "distribution"
productFlavors {
create("gplay") {
dimension = "distribution"
isDefault = true
}
create("foss") {
dimension = "distribution"
}
}

buildFeatures {
compose = true
buildConfig = true
Expand Down Expand Up @@ -74,4 +85,7 @@ dependencies {
implementation(libs.boehrsi.devicemarketingnames)
implementation(libs.telemetrydeck.sdk)
implementation(libs.topjohnwu.libsu.core)

// In-app updates (gplay flavor only)
"gplayImplementation"(libs.google.play.app.update.ktx)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.iboalali.basicrootchecker.update

import android.content.Context

@Suppress("UNUSED_PARAMETER")
fun createAppUpdateController(context: Context): AppUpdateController = NoOpAppUpdateController
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.iboalali.basicrootchecker.update

import androidx.activity.ComponentActivity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

object NoOpAppUpdateController : AppUpdateController {
override val events: StateFlow<AppUpdateEvent> =
MutableStateFlow<AppUpdateEvent>(AppUpdateEvent.None).asStateFlow()

override fun attach(activity: ComponentActivity) = Unit
override fun checkForUpdate() = Unit
override fun startFlexibleFlow() = Unit
override fun completeUpdate() = Unit
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.iboalali.basicrootchecker.update

import android.content.Context

fun createAppUpdateController(context: Context): AppUpdateController =
GPlayAppUpdateController(context.applicationContext)
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package com.iboalali.basicrootchecker.update

import android.app.Activity
import android.content.Context
import android.content.IntentSender
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.InstallStateUpdatedListener
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.InstallErrorCode
import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.install.model.UpdateAvailability
import com.iboalali.basicrootchecker.analytics.Analytics
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

private const val TAG = "GPlayAppUpdate"
private const val STALENESS_DAYS_THRESHOLD = 1

class GPlayAppUpdateController(context: Context) : AppUpdateController {

private val appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(context)

private val _events = MutableStateFlow<AppUpdateEvent>(AppUpdateEvent.None)
override val events: StateFlow<AppUpdateEvent> = _events.asStateFlow()

private var activity: ComponentActivity? = null
private var launcher: ActivityResultLauncher<IntentSenderRequest>? = null
private var latestUpdateInfo: AppUpdateInfo? = null

private val installStateListener = InstallStateUpdatedListener { state ->
when (state.installStatus()) {
InstallStatus.DOWNLOADING -> {
_events.value = AppUpdateEvent.Downloading(
state.bytesDownloaded(),
state.totalBytesToDownload(),
)
}
InstallStatus.DOWNLOADED -> {
if (_events.value !is AppUpdateEvent.Downloaded) {
Analytics.trackUpdateDownloaded()
}
_events.value = AppUpdateEvent.Downloaded
}
InstallStatus.FAILED -> {
val code = state.installErrorCode()
_events.value = AppUpdateEvent.Failed(code)
Analytics.trackUpdateFailed(formatInstallError(code))
}
else -> Unit
}
}

private val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
appUpdateManager.registerListener(installStateListener)
}

override fun onResume(owner: LifecycleOwner) {
checkForUpdate()
}

override fun onStop(owner: LifecycleOwner) {
appUpdateManager.unregisterListener(installStateListener)
}

override fun onDestroy(owner: LifecycleOwner) {
detach()
}
}

override fun attach(activity: ComponentActivity) {
if (this.activity === activity) return
if (this.activity != null) detach()

this.activity = activity
launcher = activity.registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) { result ->
if (result.resultCode != Activity.RESULT_OK) {
Log.w(TAG, "Update flow cancelled or failed: resultCode=${result.resultCode}")
}
}
activity.lifecycle.addObserver(lifecycleObserver)
}

private fun detach() {
activity?.lifecycle?.removeObserver(lifecycleObserver)
runCatching { appUpdateManager.unregisterListener(installStateListener) }
activity = null
launcher = null
latestUpdateInfo = null
}

override fun checkForUpdate() {
appUpdateManager.appUpdateInfo
.addOnSuccessListener { info ->
latestUpdateInfo = info
val current = _events.value
if (current is AppUpdateEvent.Downloading) return@addOnSuccessListener

if (info.installStatus() == InstallStatus.DOWNLOADED) {
_events.value = AppUpdateEvent.Downloaded
return@addOnSuccessListener
}

val available = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE
val flexibleAllowed = info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)
val stale = (info.clientVersionStalenessDays() ?: -1) >= STALENESS_DAYS_THRESHOLD

if (available && flexibleAllowed && stale) {
if (current !is AppUpdateEvent.Available) {
Analytics.trackUpdateAvailable()
}
_events.value = AppUpdateEvent.Available
} else if (current is AppUpdateEvent.Available) {
_events.value = AppUpdateEvent.None
}
}
.addOnFailureListener { e ->
Log.w(TAG, "requestAppUpdateInfo failed", e)
}
}

override fun startFlexibleFlow() {
val info = latestUpdateInfo ?: return
val l = launcher ?: return
try {
val started = appUpdateManager.startUpdateFlowForResult(
info,
l,
AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(),
)
if (started) {
Analytics.trackUpdateStarted()
_events.value = AppUpdateEvent.Downloading(0, 0)
}
} catch (e: IntentSender.SendIntentException) {
Log.w(TAG, "startUpdateFlowForResult failed", e)
}
}

override fun completeUpdate() {
appUpdateManager.completeUpdate()
}

private fun formatInstallError(code: Int): String {
val name = when (code) {
InstallErrorCode.NO_ERROR -> "NO_ERROR"
InstallErrorCode.ERROR_UNKNOWN -> "ERROR_UNKNOWN"
InstallErrorCode.ERROR_API_NOT_AVAILABLE -> "ERROR_API_NOT_AVAILABLE"
InstallErrorCode.ERROR_INVALID_REQUEST -> "ERROR_INVALID_REQUEST"
InstallErrorCode.ERROR_INSTALL_UNAVAILABLE -> "ERROR_INSTALL_UNAVAILABLE"
InstallErrorCode.ERROR_INSTALL_NOT_ALLOWED -> "ERROR_INSTALL_NOT_ALLOWED"
InstallErrorCode.ERROR_DOWNLOAD_NOT_PRESENT -> "ERROR_DOWNLOAD_NOT_PRESENT"
InstallErrorCode.ERROR_INTERNAL_ERROR -> "ERROR_INTERNAL_ERROR"
InstallErrorCode.ERROR_PLAY_STORE_NOT_FOUND -> "ERROR_PLAY_STORE_NOT_FOUND"
InstallErrorCode.ERROR_APP_NOT_OWNED -> "ERROR_APP_NOT_OWNED"
else -> "ERROR_UNMAPPED"
}
return "$name ($code)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ package com.iboalali.basicrootchecker
import android.app.Application
import com.iboalali.basicrootchecker.analytics.Analytics
import com.iboalali.basicrootchecker.data.UserPreferences
import com.iboalali.basicrootchecker.update.AppUpdateController
import com.iboalali.basicrootchecker.update.createAppUpdateController
import com.telemetrydeck.sdk.TelemetryDeck

class BasicRootCheckerApplication : Application() {

val appUpdateController: AppUpdateController by lazy { createAppUpdateController(this) }

override fun onCreate() {
super.onCreate()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class MainActivity : ComponentActivity() {

super.onCreate(savedInstanceState)

(application as BasicRootCheckerApplication).appUpdateController.attach(this)

// Workaround: splash screen theme doesn't properly set light status bar
val isNight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_YES) == Configuration.UI_MODE_NIGHT_YES
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = !isNight
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,27 @@ object Analytics {
mapOf("result" to result),
)
}

fun trackUpdateAvailable() {
if (!enabled) return
TelemetryDeck.signal("updateAvailable")
}

fun trackUpdateStarted() {
if (!enabled) return
TelemetryDeck.signal("updateStarted")
}

fun trackUpdateDownloaded() {
if (!enabled) return
TelemetryDeck.signal("updateDownloaded")
}

fun trackUpdateFailed(error: String) {
if (!enabled) return
TelemetryDeck.signal(
"updateFailed",
mapOf("error" to error),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.iboalali.basicrootchecker.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
Expand All @@ -27,7 +28,19 @@ class UserPreferences(private val context: Context) {
fun telemetryEnabledBlocking(): Boolean =
runBlocking { telemetryEnabled.first() }

val lastSeenVersionCode: Flow<Int> =
context.userSettingsDataStore.data.map { preferences ->
preferences[LAST_SEEN_VERSION_CODE] ?: 0
}

suspend fun setLastSeenVersionCode(code: Int) {
context.userSettingsDataStore.edit { preferences ->
preferences[LAST_SEEN_VERSION_CODE] = code
}
}

companion object {
private val TELEMETRY_ENABLED = booleanPreferencesKey("telemetry_enabled")
private val LAST_SEEN_VERSION_CODE = intPreferencesKey("last_seen_version_code")
}
}
Loading
Loading