diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ef945a640..553f98cf1 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -93,7 +93,7 @@ android {
dimension = "version"
manifestPlaceholders["label"] = "JamesDSP"
- project.setProperty("archivesBaseName", "JamesDSP-v${AndroidConfig.versionName}")
+ project.setProperty("archivesBaseName", "JamesDSP-v${AndroidConfig.versionName}-${AndroidConfig.versionCode}")
applicationId = "james.dsp"
AndroidConfig.minSdk = 26
minSdk = AndroidConfig.minSdk
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 594dbb0ca..cb90ea579 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -285,5 +285,11 @@
android:resource="@xml/provider_filelibrary_paths" />
+
+
\ No newline at end of file
diff --git a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/MainApplication.kt b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/MainApplication.kt
index 5af59c7bd..62f7cebe1 100644
--- a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/MainApplication.kt
+++ b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/MainApplication.kt
@@ -14,8 +14,10 @@ import me.timschneeberger.rootlessjamesdsp.model.room.AppBlocklistRepository
import me.timschneeberger.rootlessjamesdsp.session.dump.DumpManager
import me.timschneeberger.rootlessjamesdsp.utils.Constants
import me.timschneeberger.rootlessjamesdsp.flavor.CrashlyticsImpl
+import me.timschneeberger.rootlessjamesdsp.flavor.UpdateManager
import me.timschneeberger.rootlessjamesdsp.service.RootAudioProcessorService
import me.timschneeberger.rootlessjamesdsp.session.root.RootSessionDatabase
+import me.timschneeberger.rootlessjamesdsp.utils.Cache
import me.timschneeberger.rootlessjamesdsp.utils.ContextExtensions.registerLocalReceiver
import me.timschneeberger.rootlessjamesdsp.utils.Preferences
import org.koin.android.ext.android.inject
@@ -82,6 +84,9 @@ class MainApplication : Application(), SharedPreferences.OnSharedPreferenceChang
if(!BuildConfig.FOSS_ONLY)
Timber.plant(CrashReportingTree())
+ // Clean up
+ Cache.cleanup(this)
+
Timber.plant(FileLoggerTree.Builder()
.withFileName("application.log")
.withDirName(this.cacheDir.absolutePath)
@@ -92,7 +97,7 @@ class MainApplication : Application(), SharedPreferences.OnSharedPreferenceChang
.build())
Timber.i("====> Application starting up")
- // Clean up
+ // TODO: use cache
val dumpFile = File(filesDir, "dump.txt")
if(dumpFile.exists()) {
dumpFile.delete()
@@ -100,6 +105,7 @@ class MainApplication : Application(), SharedPreferences.OnSharedPreferenceChang
val appModule = module {
single { DumpManager(androidContext()) }
+ single { UpdateManager(androidContext()) }
single { Preferences(androidContext()).App() }
single { Preferences(androidContext()).Var() }
}
diff --git a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/activity/MainActivity.kt b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/activity/MainActivity.kt
index f1eee3ccd..611a47652 100644
--- a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/activity/MainActivity.kt
+++ b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/activity/MainActivity.kt
@@ -18,18 +18,21 @@ import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.preference.DialogPreference.TargetFragment
import androidx.preference.Preference
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import me.timschneeberger.rootlessjamesdsp.BuildConfig
import me.timschneeberger.rootlessjamesdsp.MainApplication
import me.timschneeberger.rootlessjamesdsp.R
import me.timschneeberger.rootlessjamesdsp.databinding.ActivityMainBinding
import me.timschneeberger.rootlessjamesdsp.databinding.ContentMainBinding
import me.timschneeberger.rootlessjamesdsp.flavor.CrashlyticsImpl
+import me.timschneeberger.rootlessjamesdsp.flavor.UpdateManager
import me.timschneeberger.rootlessjamesdsp.fragment.DspFragment
import me.timschneeberger.rootlessjamesdsp.fragment.FileLibraryDialogFragment
import me.timschneeberger.rootlessjamesdsp.fragment.LibraryLoadErrorFragment
@@ -53,9 +56,11 @@ import me.timschneeberger.rootlessjamesdsp.utils.ContextExtensions.showSingleCho
import me.timschneeberger.rootlessjamesdsp.utils.ContextExtensions.showYesNoAlert
import me.timschneeberger.rootlessjamesdsp.utils.ContextExtensions.toast
import me.timschneeberger.rootlessjamesdsp.utils.ContextExtensions.unregisterLocalReceiver
+import me.timschneeberger.rootlessjamesdsp.utils.Result
import me.timschneeberger.rootlessjamesdsp.utils.StorageUtils
import me.timschneeberger.rootlessjamesdsp.utils.SystemServices
import me.timschneeberger.rootlessjamesdsp.view.FloatingToggleButton
+import org.koin.core.component.inject
import timber.log.Timber
import java.io.File
import java.util.*
@@ -75,6 +80,7 @@ class MainActivity : BaseActivity() {
/* Root version */
private var hasLoadFailed = false
private lateinit var runtimePermissionLauncher: ActivityResultLauncher>
+ private val updateManager: UpdateManager by inject()
private var processorService: BaseAudioProcessorService? = null
private var processorServiceBound: Boolean = false
@@ -337,6 +343,10 @@ class MainActivity : BaseActivity() {
}
}
+ dspFragment.setUpdateCardOnClick { updateManager.installUpdate(this) }
+ dspFragment.setUpdateCardOnCloseClick(::dismissUpdate)
+ checkForUpdates()
+
// Handle potential incoming file intent
if(intent?.action == Intent.ACTION_VIEW) {
intent.data?.let { handleFileIntent(it) }
@@ -402,6 +412,77 @@ class MainActivity : BaseActivity() {
excludeAppFromRecents()
}
+ private fun checkForUpdates() {
+ if(BuildConfig.ROOTLESS ||
+ prefsVar.get(R.string.key_update_check_timeout) > (System.currentTimeMillis() / 1000L)) {
+ Timber.d("Update check rejected due to flavor or timeout")
+ return
+ }
+
+ CoroutineScope(Dispatchers.Default).launch {
+ updateManager.isUpdateAvailable().collect {
+ when(it) {
+ is Result.Error -> {
+ Timber.e("Update check failed")
+ Timber.d(it.exception)
+ // Set timeout to +30min
+ prefsVar.set(R.string.key_update_check_timeout, (System.currentTimeMillis() / 1000L) + 1800L)
+ false
+ }
+ is Result.Success -> {
+ Timber.d("Is update available? ${it.data}")
+ if(!it.data) {
+ // Set timeout to +4h
+ prefsVar.set(R.string.key_update_check_timeout, (System.currentTimeMillis() / 1000L) + 14400L)
+ }
+ it.data
+ }
+ else -> false
+ }.let {
+ withContext(Dispatchers.Main) {
+ val info = updateManager.getUpdateVersionInfo()
+ val skipUpdate = info?.second == prefsVar.get(R.string.key_update_check_skip)
+ Timber.d("Should skip update ${info?.second}?: $skipUpdate")
+ if(skipUpdate) {
+ // Set timeout to +4h
+ prefsVar.set(R.string.key_update_check_timeout, (System.currentTimeMillis() / 1000L) + 14400L)
+ }
+ dspFragment.setUpdateCardTitle(getString(R.string.self_update_notice, info?.first ?: "..."))
+ dspFragment.setUpdateCardVisible(it && !skipUpdate)
+ }
+ }
+ }
+ }
+ }
+
+ private fun dismissUpdate() {
+ if(BuildConfig.ROOTLESS)
+ return
+
+ MaterialAlertDialogBuilder(this)
+ .setTitle(getString(R.string.actions))
+ .setItems(R.array.update_dismiss_dialog) { dialogInterface, i ->
+ when(i) {
+ 0 -> updateManager.installUpdate(this)
+ 1 -> {
+ prefsVar.set(R.string.key_update_check_skip, updateManager.getUpdateVersionInfo()?.second ?: 0)
+ dspFragment.setUpdateCardVisible(false)
+ }
+ 2 -> {
+ prefsVar.set(
+ R.string.key_update_check_timeout,
+ (System.currentTimeMillis() / 1000L) + 43200L /* +12h snooze */
+ )
+ dspFragment.setUpdateCardVisible(false)
+ }
+ }
+ dialogInterface.dismiss()
+ }
+ .setNegativeButton(getString(android.R.string.cancel)){ _, _ -> }
+ .create()
+ .show()
+ }
+
private fun excludeAppFromRecents() {
getSystemService()?.appTasks?.takeIf { it.isNotEmpty() }?.forEach {
it.setExcludeFromRecents(prefsApp.get(R.string.key_exclude_app_from_recents))
@@ -409,6 +490,9 @@ class MainActivity : BaseActivity() {
}
private fun showLibraryLoadError() {
+ if(DEBUG_IGNORE_MISSING_LIBRARY)
+ return
+
hasLoadFailed = true
supportFragmentManager
@@ -631,6 +715,7 @@ class MainActivity : BaseActivity() {
companion object {
const val EXTRA_FORCE_SHOW_CAPTURE_PROMPT = "ForceShowCapturePrompt"
+ private val DEBUG_IGNORE_MISSING_LIBRARY = BuildConfig.DEBUG
private const val STATE_LOAD_FAILED = "LoadFailed"
}
}
\ No newline at end of file
diff --git a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/fragment/DspFragment.kt b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/fragment/DspFragment.kt
index 005a77b80..2a707c707 100644
--- a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/fragment/DspFragment.kt
+++ b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/fragment/DspFragment.kt
@@ -19,6 +19,9 @@ class DspFragment : Fragment() {
private val prefsVar: Preferences.Var by inject()
private var translateNotice: Card? = null
+ private var updateNotice: Card? = null
+ private var updateNoticeOnClick: (() -> Unit)? = null
+ private var updateNoticeOnCloseClick: (() -> Unit)? = null
override fun onCreateView(
inflater: LayoutInflater,
@@ -27,15 +30,25 @@ class DspFragment : Fragment() {
): View? {
val view = inflater.inflate(R.layout.fragment_dsp, container, false)
translateNotice = view.findViewById(R.id.translation_notice)
+ updateNotice = view.findViewById(R.id.update_notice)
+
translateNotice?.setOnCloseClickListener(::hideTranslationNotice)
translateNotice?.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://crowdin.com/project/rootlessjamesdsp")))
hideTranslationNotice()
}
- // Should show translation notice?
+ updateNotice?.setOnCloseClickListener {
+ updateNoticeOnCloseClick?.invoke()
+ }
+ updateNotice?.setOnClickListener {
+ updateNoticeOnClick?.invoke()
+ }
+
+ // Should show notice?
translateNotice?.isVisible =
prefsVar.get(R.string.key_snooze_translation_notice) < (System.currentTimeMillis() / 1000L)
+ updateNotice?.isVisible = false
val transition = LayoutTransition()
transition.enableTransitionType(LayoutTransition.CHANGING)
@@ -100,6 +113,22 @@ class DspFragment : Fragment() {
prefsVar.set(R.string.key_snooze_translation_notice, (System.currentTimeMillis() / 1000L) + 31536000L)
}
+ fun setUpdateCardVisible(visible: Boolean) {
+ updateNotice?.isVisible = visible
+ }
+
+ fun setUpdateCardTitle(title: String) {
+ updateNotice?.titleText = title
+ }
+
+ fun setUpdateCardOnClick(onClick: () -> Unit) {
+ updateNoticeOnClick = onClick
+ }
+
+ fun setUpdateCardOnCloseClick(onClick: () -> Unit) {
+ updateNoticeOnCloseClick = onClick
+ }
+
fun restartFragment(id: Int, newFragment: Fragment) {
try {
childFragmentManager.beginTransaction()
diff --git a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/fragment/SettingsAboutFragment.kt b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/fragment/SettingsAboutFragment.kt
index 1f50b06cd..e7bafebad 100644
--- a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/fragment/SettingsAboutFragment.kt
+++ b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/fragment/SettingsAboutFragment.kt
@@ -13,16 +13,29 @@ import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceGroup
import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import me.timschneeberger.rootlessjamesdsp.BuildConfig
import me.timschneeberger.rootlessjamesdsp.R
+import me.timschneeberger.rootlessjamesdsp.flavor.UpdateManager
import me.timschneeberger.rootlessjamesdsp.model.Translator
+import me.timschneeberger.rootlessjamesdsp.utils.ContextExtensions.toast
+import me.timschneeberger.rootlessjamesdsp.utils.Result
+import org.koin.android.ext.android.inject
+import timber.log.Timber
import java.util.Locale
class SettingsAboutFragment : PreferenceFragmentCompat() {
+ private val updateManager: UpdateManager by inject()
+
private val version by lazy { findPreference(getString(R.string.key_credits_version)) }
private val buildInfo by lazy { findPreference(getString(R.string.key_credits_build_info)) }
+ private val googlePlay by lazy { findPreference(getString(R.string.key_credits_google_play)) }
+ private val selfCheckUpdates by lazy { findPreference(getString(R.string.key_credits_check_update)) }
private val translatorsGroup by lazy { findPreference(getString(R.string.key_translators)) }
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@@ -39,6 +52,13 @@ class SettingsAboutFragment : PreferenceFragmentCompat() {
buildInfo?.summary = "$type build (${BuildConfig.FLAVOR_dependencies}) @${BuildConfig.COMMIT_SHA} (compiled at ${BuildConfig.BUILD_TIME})"
+ googlePlay?.isVisible = BuildConfig.ROOTLESS
+ selfCheckUpdates?.isVisible = !BuildConfig.ROOTLESS
+ selfCheckUpdates?.setOnPreferenceClickListener {
+ checkForUpdates()
+ true
+ }
+
Translator.readLanguageMap(requireContext()).forEach { (cc, tls) ->
translatorsGroup?.addPreference(Preference(requireContext()).apply {
val language = Locale.forLanguageTag(cc).getDisplayLanguage(requireContext().resources.configuration.locales[0])
@@ -84,6 +104,27 @@ class SettingsAboutFragment : PreferenceFragmentCompat() {
return view
}
+ private fun checkForUpdates() {
+ if(BuildConfig.ROOTLESS)
+ return
+
+ CoroutineScope(Dispatchers.Default).launch {
+ updateManager.isUpdateAvailable().collect {
+ when(it) {
+ is Result.Success -> it.data
+ else -> false
+ }.let { hasUpdate ->
+ withContext(Dispatchers.Main) {
+ if (hasUpdate)
+ updateManager.installUpdate(requireActivity())
+ else
+ requireContext().toast(getString(R.string.self_update_no_updates))
+ }
+ }
+ }
+ }
+ }
+
companion object {
fun newInstance(): SettingsAboutFragment {
return SettingsAboutFragment()
diff --git a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/session/shared/BaseSessionDatabase.kt b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/session/shared/BaseSessionDatabase.kt
index 7ca14b21f..1a1d5389d 100644
--- a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/session/shared/BaseSessionDatabase.kt
+++ b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/session/shared/BaseSessionDatabase.kt
@@ -103,6 +103,7 @@ abstract class BaseSessionDatabase(protected val context: Context) {
}
}
+ // TODO use synchronized() block
fun setExcludedUids(uids: Array) {
excludedUids = uids
diff --git a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/ApiExtensions.kt b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/ApiExtensions.kt
new file mode 100644
index 000000000..9f8ff34c8
--- /dev/null
+++ b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/ApiExtensions.kt
@@ -0,0 +1,52 @@
+package me.timschneeberger.rootlessjamesdsp.utils
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import okhttp3.ResponseBody
+import java.io.File
+
+object ApiExtensions {
+ sealed class DownloadState {
+ data class Downloading(val progress: Int, val currentBytes: Long, val totalBytes: Long) : DownloadState()
+ data class Finished(val file: File) : DownloadState()
+ data class Failed(val error: Throwable? = null) : DownloadState()
+ }
+
+ fun ResponseBody.save(destinationFile: File): Flow {
+ return flow {
+ emit(DownloadState.Downloading(0, 0, contentLength()))
+
+ try {
+ byteStream().use { inputStream ->
+ destinationFile.outputStream().use { outputStream ->
+ val totalBytes = contentLength()
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var progressBytes = 0L
+
+ var bytes = inputStream.read(buffer)
+ while (bytes >= 0) {
+ outputStream.write(buffer, 0, bytes)
+ progressBytes += bytes
+ bytes = inputStream.read(buffer)
+ emit(
+ DownloadState.Downloading(
+ ((progressBytes * 100) / totalBytes).toInt(),
+ progressBytes,
+ totalBytes
+ )
+ )
+ }
+ }
+ }
+ emit(DownloadState.Finished(destinationFile))
+ } catch (e: Exception) {
+ emit(DownloadState.Failed(e))
+ }
+ }
+ .flowOn(Dispatchers.IO)
+ .distinctUntilChanged()
+ }
+}
diff --git a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/Cache.kt b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/Cache.kt
new file mode 100644
index 000000000..cd6e4ab6e
--- /dev/null
+++ b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/Cache.kt
@@ -0,0 +1,226 @@
+package me.timschneeberger.rootlessjamesdsp.utils
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.content.pm.PackageManager
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import android.provider.OpenableColumns
+import android.system.Os
+import java.io.File
+import java.util.*
+import kotlin.concurrent.thread
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.seconds
+
+// Based on https://github.com/Iamlooker/Droid-ify/blob/main/core/common/src/main/java/com/looker/core/common/cache/Cache.kt
+
+object Cache {
+
+ private const val RELEASE_DIR = "releases"
+ private const val TEMP_DIR = "temporary"
+
+ private fun ensureCacheDir(context: Context, name: String): File {
+ return File(
+ context.cacheDir,
+ name
+ ).apply { isDirectory || mkdirs() }
+ }
+
+ private fun applyOrMode(file: File, mode: Int) {
+ val oldMode = Os.stat(file.path).st_mode and 0b111111111111
+ val newMode = oldMode or mode
+ if (newMode != oldMode) {
+ Os.chmod(file.path, newMode)
+ }
+ }
+
+ private fun subPath(dir: File, file: File): String {
+ val dirPath = "${dir.path}/"
+ val filePath = file.path
+ filePath.startsWith(dirPath) || throw RuntimeException()
+ return filePath.substring(dirPath.length)
+ }
+ fun getReleaseFile(context: Context, cacheFileName: String): File {
+ return File(ensureCacheDir(context, RELEASE_DIR), cacheFileName).apply {
+ sdkAbove(Build.VERSION_CODES.N) {
+ // Make readable for package installer
+ val cacheDir = context.cacheDir.parentFile!!.parentFile!!
+ generateSequence(this) { it.parentFile!! }.takeWhile { it != cacheDir }.forEach {
+ when {
+ it.isDirectory -> applyOrMode(it, 0b001001001)
+ it.isFile -> applyOrMode(it, 0b100100100)
+ }
+ }
+ }
+ }
+ }
+
+ fun getReleaseUri(context: Context, cacheFileName: String): Uri {
+ val file = getReleaseFile(context, cacheFileName)
+ val packageInfo =
+ try {
+ if (SdkCheck.isTiramisu) {
+ context.packageManager.getPackageInfo(
+ context.packageName,
+ PackageManager.PackageInfoFlags.of(PackageManager.GET_PROVIDERS.toLong())
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ context.packageManager.getPackageInfo(
+ context.packageName,
+ PackageManager.GET_PROVIDERS
+ )
+ }
+ } catch (e: Exception) {
+ null
+ }
+ val authority =
+ packageInfo?.providers?.find { it.name == Provider::class.java.name }!!.authority
+ return Uri.Builder()
+ .scheme("content")
+ .authority(authority)
+ .encodedPath(file.path.drop(context.cacheDir.path.length))
+ .build()
+ }
+
+ fun getTemporaryFile(context: Context): File {
+ return File(ensureCacheDir(context, TEMP_DIR), UUID.randomUUID().toString())
+ }
+
+ fun cleanupNow(context: Context) {
+ thread {
+ cleanup(
+ context,
+ Pair(RELEASE_DIR, Duration.ZERO),
+ Pair(TEMP_DIR, Duration.ZERO)
+ )
+ }
+ }
+
+ fun cleanup(context: Context) {
+ thread {
+ cleanup(
+ context,
+ Pair(RELEASE_DIR, 24.hours),
+ Pair(TEMP_DIR, 1.hours)
+ )
+ }
+ }
+
+ private fun cleanup(context: Context, vararg dirHours: Pair) {
+ val knownNames = dirHours.asSequence().map { it.first }.toSet()
+ val files = context.cacheDir.listFiles().orEmpty()
+ files.asSequence().filter { it.name !in knownNames }.forEach {
+ if (it.isDirectory) {
+ cleanupDir(it, Duration.ZERO)
+ it.delete()
+ } else {
+ it.delete()
+ }
+ }
+ dirHours.forEach { (name, duration) ->
+ val file = File(context.cacheDir, name)
+ if (file.exists()) {
+ if (file.isDirectory) {
+ cleanupDir(file, duration)
+ } else {
+ file.delete()
+ }
+ }
+ }
+ }
+
+ private fun cleanupDir(dir: File, duration: Duration) {
+ dir.listFiles()?.forEach {
+ val older = duration <= Duration.ZERO || run {
+ val olderThan = System.currentTimeMillis() / 1000L - duration.inWholeSeconds
+ try {
+ val stat = Os.lstat(it.path)
+ stat.st_atime < olderThan
+ } catch (e: Exception) {
+ false
+ }
+ }
+ if (older) {
+ if (it.isDirectory) {
+ cleanupDir(it, duration)
+ if (it.isDirectory) {
+ it.delete()
+ }
+ } else {
+ it.delete()
+ }
+ }
+ }
+ }
+
+ class Provider : ContentProvider() {
+ companion object {
+ private val defaultColumns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
+ }
+
+ private fun getFileAndTypeForUri(uri: Uri): Pair {
+ return when (uri.pathSegments?.firstOrNull()) {
+ RELEASE_DIR -> Pair(
+ File(context!!.cacheDir, uri.encodedPath!!),
+ "application/vnd.android.package-archive"
+ )
+ else -> throw SecurityException()
+ }
+ }
+
+ override fun onCreate(): Boolean = true
+
+ override fun query(
+ uri: Uri, projection: Array?,
+ selection: String?, selectionArgs: Array?, sortOrder: String?,
+ ): Cursor {
+ val file = getFileAndTypeForUri(uri).first
+ val columns = (projection ?: defaultColumns).mapNotNull {
+ when (it) {
+ OpenableColumns.DISPLAY_NAME -> Pair(it, file.name)
+ OpenableColumns.SIZE -> Pair(it, file.length())
+ else -> null
+ }
+ }.unzip()
+ return MatrixCursor(columns.first.toTypedArray()).apply { addRow(columns.second.toTypedArray()) }
+ }
+
+ override fun getType(uri: Uri): String = getFileAndTypeForUri(uri).second
+
+ private val unsupported: Nothing
+ get() = throw UnsupportedOperationException()
+
+ override fun insert(uri: Uri, contentValues: ContentValues?): Uri = unsupported
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int =
+ unsupported
+
+ override fun update(
+ uri: Uri, contentValues: ContentValues?,
+ selection: String?, selectionArgs: Array?,
+ ): Int = unsupported
+
+ override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
+ val openMode = when (mode) {
+ "r" -> ParcelFileDescriptor.MODE_READ_ONLY
+ "w", "wt" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or
+ ParcelFileDescriptor.MODE_TRUNCATE
+ "wa" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or
+ ParcelFileDescriptor.MODE_APPEND
+ "rw" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE
+ "rwt" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE or
+ ParcelFileDescriptor.MODE_TRUNCATE
+ else -> throw IllegalArgumentException()
+ }
+ val file = getFileAndTypeForUri(uri).first
+ return ParcelFileDescriptor.open(file, openMode)
+ }
+ }
+}
+
diff --git a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/ContextExtensions.kt b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/ContextExtensions.kt
index 6d3635f9f..e6bc2a439 100644
--- a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/ContextExtensions.kt
+++ b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/ContextExtensions.kt
@@ -34,6 +34,7 @@ import me.timschneeberger.rootlessjamesdsp.BuildConfig
import me.timschneeberger.rootlessjamesdsp.R
import me.timschneeberger.rootlessjamesdsp.databinding.DialogTextinputBinding
import timber.log.Timber
+import java.io.File
import kotlin.math.roundToInt
@@ -343,4 +344,9 @@ object ContextExtensions {
val imm = SystemServices.get(this)
imm.hideSoftInputFromWindow(view.windowToken, 0)
}
+
+ fun Context.ensureCacheDir(name: String): File {
+ return File(cacheDir, name).apply { isDirectory || mkdirs() }
+ }
+
}
\ No newline at end of file
diff --git a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/Result.kt b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/Result.kt
new file mode 100644
index 000000000..c89855f5c
--- /dev/null
+++ b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/Result.kt
@@ -0,0 +1,24 @@
+package me.timschneeberger.rootlessjamesdsp.utils
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+
+sealed interface Result {
+ data class Success(val data: T) : Result
+
+ data class Error(
+ val exception: Throwable? = null,
+ val data: T? = null
+ ) : Result
+
+ object Loading : Result
+}
+
+fun Flow.asResult(): Flow> {
+ return this
+ .map> { Result.Success(it) }
+ .onStart { emit(Result.Loading) }
+ .catch { emit(Result.Error(it)) }
+}
\ No newline at end of file
diff --git a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/SdkCheck.kt b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/SdkCheck.kt
new file mode 100644
index 000000000..a51a97493
--- /dev/null
+++ b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/utils/SdkCheck.kt
@@ -0,0 +1,42 @@
+package me.timschneeberger.rootlessjamesdsp.utils
+
+import android.os.Build
+import androidx.annotation.ChecksSdkIntAtLeast
+
+@ChecksSdkIntAtLeast(parameter = 0, lambda = 1)
+inline fun sdkAbove(sdk: Int, onSuccessful: () -> Unit) {
+ if (Build.VERSION.SDK_INT >= sdk) onSuccessful()
+}
+
+object SdkCheck {
+
+ private val sdk: Int
+ get() = Build.VERSION.SDK_INT
+
+ // Allows auto install if target sdk of apk is one less then current sdk
+ fun canAutoInstall(targetSdk: Int) = targetSdk >= sdk - 1 && isSnowCake
+
+ @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU)
+ val isTiramisu: Boolean
+ get() = sdk >= Build.VERSION_CODES.TIRAMISU
+
+ @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R)
+ val isR: Boolean
+ get() = sdk >= Build.VERSION_CODES.R
+
+ @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P)
+ val isPie: Boolean
+ get() = sdk >= Build.VERSION_CODES.P
+
+ @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
+ val isOreo: Boolean
+ get() = sdk >= Build.VERSION_CODES.O
+
+ @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
+ val isSnowCake: Boolean
+ get() = sdk >= Build.VERSION_CODES.S
+
+ @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N)
+ val isNougat: Boolean
+ get() = sdk >= Build.VERSION_CODES.N
+}
\ No newline at end of file
diff --git a/app/src/main/java/me/timschneeberger/rootlessjamesdsp/view/ProgressDialog.kt b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/view/ProgressDialog.kt
new file mode 100644
index 000000000..36f201b2d
--- /dev/null
+++ b/app/src/main/java/me/timschneeberger/rootlessjamesdsp/view/ProgressDialog.kt
@@ -0,0 +1,92 @@
+package me.timschneeberger.rootlessjamesdsp.view
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.DialogInterface
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import me.timschneeberger.rootlessjamesdsp.databinding.DialogProgressBinding
+import me.timschneeberger.rootlessjamesdsp.utils.SystemServices
+import kotlin.math.roundToInt
+
+class ProgressDialog(
+ val context: Context,
+ onCancelListener: ((DialogInterface) -> Unit)?
+) {
+ private val dialog: AlertDialog
+ private val binding: DialogProgressBinding =
+ DialogProgressBinding.inflate(SystemServices.get(context))
+
+ var title: CharSequence
+ set(value) {
+ binding.alertTitle.text = value
+ }
+ get() = binding.alertTitle.text
+
+ var unit: String = ""
+ set(value) {
+ field = value
+ updateProgress()
+ }
+ var divisor: Double = 1.0
+ set(value) {
+ field = value
+ updateProgress()
+ }
+
+ var currentProgress: Int = 0
+ set(value) {
+ field = value
+ updateProgress()
+ }
+ var maxProgress: Int
+ set(value) {
+ binding.progress.max = value
+ updateProgress()
+ }
+ get() = binding.progress.max
+
+ var isIndeterminate: Boolean
+ set(value) {
+ binding.progress.isIndeterminate = value
+ updateProgress()
+ }
+ get() = binding.progress.isIndeterminate
+
+ var isCancelable: Boolean = true
+ set(value) {
+ field = value
+ dialog.setCancelable(value)
+ dialog.getButton(DialogInterface.BUTTON_NEGATIVE).isEnabled = value
+ }
+
+ init {
+ dialog = MaterialAlertDialogBuilder(context)
+ .setCancelable(isCancelable)
+ .setOnCancelListener(onCancelListener)
+ .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() }
+ .setView(binding.root)
+ .show()
+ updateProgress()
+ }
+
+ fun dismiss() = dialog.dismiss()
+ fun cancel() = dialog.cancel()
+
+ @SuppressLint("SetTextI18n")
+ private fun updateProgress() {
+ binding.progressPercent.visibility = if(isIndeterminate) View.GONE else View.VISIBLE
+ binding.progressNumber.visibility = if(isIndeterminate) View.GONE else View.VISIBLE
+
+ val percent = ((currentProgress / maxProgress.toDouble()) * 100.0).roundToInt()
+ binding.progressPercent.text = "$percent%"
+ binding.progress.progress = currentProgress
+
+ val current = currentProgress / divisor
+ val max = maxProgress / divisor
+ binding.progressNumber.text = "${String.format("%.1f", current)}/${String.format("%.1f", max)}$unit"
+ }
+}
diff --git a/app/src/main/res/drawable/ic_baseline_download_24dp.xml b/app/src/main/res/drawable/ic_baseline_download_24dp.xml
new file mode 100644
index 000000000..1fc810652
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_download_24dp.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/layout/dialog_progress.xml b/app/src/main/res/layout/dialog_progress.xml
new file mode 100644
index 000000000..102844b56
--- /dev/null
+++ b/app/src/main/res/layout/dialog_progress.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_dsp.xml b/app/src/main/res/layout/fragment_dsp.xml
index 1c77cb2cc..70a827f95 100644
--- a/app/src/main/res/layout/fragment_dsp.xml
+++ b/app/src/main/res/layout/fragment_dsp.xml
@@ -33,6 +33,20 @@
app:iconSrc="@drawable/ic_twotone_translate_24dp"
app:iconTint="?attr/colorOnSecondaryContainer" />
+
+
+
+ - @string/self_update_notice_dismiss_install
+ - @string/self_update_notice_dismiss_skip
+ - @string/self_update_notice_dismiss_snooze
+
+
- @string/session_detection_method_audioservice
- @string/session_detection_method_audiopolicyservice
diff --git a/app/src/main/res/values/defaults.xml b/app/src/main/res/values/defaults.xml
index f3b5539ea..06062262c 100644
--- a/app/src/main/res/values/defaults.xml
+++ b/app/src/main/res/values/defaults.xml
@@ -7,6 +7,8 @@
true
0
+ 0
+ 0
false
false
false
diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml
index 7dc8fad57..c0304735c 100644
--- a/app/src/main/res/values/keys.xml
+++ b/app/src/main/res/values/keys.xml
@@ -3,6 +3,8 @@
first_boot
snooze_translation_notice
+ update_check_timeout
+ update_check_skip
reset_proc_mode_fix_applied
is_activity_active
is_app_compat_activity_active
@@ -48,6 +50,8 @@
troubleshooting_view_limitations
credits_version
credits_build_info
+ credits_check_update
+ credits_google_play
audioformat_enhanced_processing_info
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3c2e40f62..c24acde49 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -43,7 +43,9 @@
Install
Update
+ Unknown error
Details
+ Actions
Warning
Success
Open
@@ -332,6 +334,8 @@
Report issues, keep yourself updated, and read the source code
Visit RootlessJamesDSP on Google Play
Search for new updates
+ Check for new updates…
+ Download and install updates if available
Contribute translations
Help us to translate this app into your language
Application version
@@ -531,4 +535,18 @@
Please help us to translate this app into your language! Tap here to visit the project site on Crowdin.
Contribute translations
+
+ Update failed
+ Failed to download update package. Please check your internet connection. Details: %1$s
+ Installation failed. %1$s
+ Update installed. The app is now restarting…
+ No new updates available at the moment
+ New update available (%1$s)
+ Tap here to download and install the new update.
+ Install now
+ Remind me later
+ Skip this update
+ Downloading…
+ Installing…
+
diff --git a/app/src/main/res/xml/app_about_preferences.xml b/app/src/main/res/xml/app_about_preferences.xml
index 37eb9456e..22251f6ab 100644
--- a/app/src/main/res/xml/app_about_preferences.xml
+++ b/app/src/main/res/xml/app_about_preferences.xml
@@ -29,6 +29,7 @@
@@ -37,6 +38,13 @@
android:data="https://play.google.com/store/apps/details?id=me.timschneeberger.rootlessjamesdsp" />
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/UpdateManager.kt b/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/UpdateManager.kt
new file mode 100644
index 000000000..f7df53fc7
--- /dev/null
+++ b/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/UpdateManager.kt
@@ -0,0 +1,154 @@
+package me.timschneeberger.rootlessjamesdsp.flavor
+
+import android.content.Context
+import android.os.Build
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import me.timschneeberger.rootlessjamesdsp.R
+import me.timschneeberger.rootlessjamesdsp.flavor.updates.SessionInstaller
+import me.timschneeberger.rootlessjamesdsp.flavor.updates.api.UpdateCheckClient
+import me.timschneeberger.rootlessjamesdsp.flavor.updates.model.UpdateCheckResponse
+import me.timschneeberger.rootlessjamesdsp.utils.ApiExtensions
+import me.timschneeberger.rootlessjamesdsp.utils.Cache
+import me.timschneeberger.rootlessjamesdsp.utils.ContextExtensions.showAlert
+import me.timschneeberger.rootlessjamesdsp.utils.Result
+import me.timschneeberger.rootlessjamesdsp.utils.sdkAbove
+import me.timschneeberger.rootlessjamesdsp.view.ProgressDialog
+import timber.log.Timber
+
+class UpdateManager(val context: Context) {
+ private val installer = SessionInstaller(context)
+ private val updateClient = UpdateCheckClient(context)
+ private var lastUpdateInfo: UpdateCheckResponse? = null
+
+ fun getUpdateVersionInfo(): Pair? {
+ return lastUpdateInfo?.let { info ->
+ info.versionName?.let { Pair(it, info.versionCode ?: 0) }
+ }
+ }
+
+ suspend fun isUpdateAvailable(): Flow> {
+ return updateClient.checkUpdate().map {
+ when(it) {
+ is Result.Error -> Result.Error(it.exception)
+ is Result.Success -> Result.Success(it.data != null).also { _ -> lastUpdateInfo = it.data }
+ is Result.Loading -> Result.Loading
+ }
+ }
+ }
+
+ fun installUpdate(context: Context) {
+ sdkAbove(Build.VERSION_CODES.S) {
+ assert(context.isUiContext)
+ }
+
+ var job: Job? = null
+ val dialog = ProgressDialog(context) { job?.cancel() }
+ dialog.isIndeterminate = true
+
+ val handleError = fun(msg: String) {
+ dialog.cancel()
+ context.showAlert(context.getString(R.string.self_update_install_error), msg)
+ }
+
+ job = CoroutineScope(Dispatchers.Default).launch {
+ createInstallFlow().collect {
+ withContext(Dispatchers.Main) {
+ when (it) {
+ is InstallState.PrepareFailed -> handleError(
+ context.getString(
+ R.string.self_update_download_fail,
+ "Data inconsistency"
+ )
+ )
+
+ is InstallState.Downloading -> {
+ dialog.apply {
+ isIndeterminate = false
+ title = context.getString(R.string.self_update_state_downloading)
+ unit = "MB"
+ divisor = 1e6
+ currentProgress = it.currentBytes.toInt()
+ maxProgress = it.totalBytes.toInt()
+ }
+ }
+
+ is InstallState.DownloadFailed -> handleError(
+ context.getString(
+ R.string.self_update_download_fail,
+ it.error?.localizedMessage ?: context.getString(R.string.unknown_error)
+ )
+ )
+
+ is InstallState.Installing -> {
+ dialog.apply {
+ isCancelable = false
+ isIndeterminate = true
+ title = context.getString(R.string.self_update_state_installing)
+ }
+ }
+
+ is InstallState.InstallDone -> dialog.dismiss()
+ }
+ }
+ }
+ }
+
+ job.invokeOnCompletion {
+ // cleanup if download cancelled
+ if(it != null)
+ Cache.cleanupNow(context)
+ }
+ }
+
+ private suspend fun createInstallFlow(): Flow {
+ return flow {
+ val info = lastUpdateInfo
+ if(info == null) {
+ Timber.e("lastUpdateInfo is null. Cannot request update download.")
+ emit(InstallState.PrepareFailed)
+ return@flow
+ }
+
+ val targetName = "${info.versionCode.toString()}.apk"
+ if(Cache.getReleaseFile(context, targetName).exists()) {
+ // Already downloaded
+ emit(InstallState.Installing)
+ installer.performInstall(targetName)
+ emit(InstallState.InstallDone)
+ return@flow
+ }
+
+ updateClient.downloadUpdate(info).collect {
+ val state = when(it) {
+ is ApiExtensions.DownloadState.Downloading -> InstallState.Downloading(it)
+ is ApiExtensions.DownloadState.Failed -> InstallState.DownloadFailed(it.error)
+ else -> InstallState.Installing
+ }
+ emit(state)
+
+ if(state is InstallState.Installing && it is ApiExtensions.DownloadState.Finished) {
+ installer.performInstall(it.file.name)
+ emit(InstallState.InstallDone)
+ }
+ }
+ }
+ }
+
+ sealed class InstallState {
+ object PrepareFailed : InstallState()
+ data class Downloading(val progress: Int, val currentBytes: Long, val totalBytes: Long) : InstallState() {
+ constructor(copy: ApiExtensions.DownloadState.Downloading) : this(copy.progress, copy.currentBytes, copy.totalBytes)
+ }
+ data class DownloadFailed(val error: Throwable? = null) : InstallState()
+ object Installing : InstallState()
+ object InstallDone: InstallState()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/SessionInstaller.kt b/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/SessionInstaller.kt
new file mode 100644
index 000000000..924e0a224
--- /dev/null
+++ b/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/SessionInstaller.kt
@@ -0,0 +1,79 @@
+package me.timschneeberger.rootlessjamesdsp.flavor.updates
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import kotlinx.coroutines.suspendCancellableCoroutine
+import me.timschneeberger.rootlessjamesdsp.utils.Cache
+import me.timschneeberger.rootlessjamesdsp.utils.SdkCheck
+import me.timschneeberger.rootlessjamesdsp.utils.sdkAbove
+import kotlin.coroutines.resume
+
+/** Based on https://github.com/Iamlooker/Droid-ify/ licensed under GPLv3 */
+class SessionInstaller(private val context: Context) {
+
+ private val sessionInstaller = context.packageManager.packageInstaller
+ private val intent = Intent(context, SessionInstallerService::class.java)
+
+ companion object {
+ private var installerCallbacks = mutableListOf()
+ private val flags = if (SdkCheck.isSnowCake) PendingIntent.FLAG_MUTABLE else 0
+ private val sessionParams =
+ PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
+ sdkAbove(sdk = Build.VERSION_CODES.S) {
+ setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
+ }
+ }
+ }
+
+ suspend fun performInstall(
+ installItem: String
+ ): Boolean = suspendCancellableCoroutine { cont ->
+
+ val cacheFile = Cache.getReleaseFile(context, installItem)
+ val id = sessionInstaller.createSession(sessionParams)
+ val installerCallback = object : PackageInstaller.SessionCallback() {
+ override fun onCreated(sessionId: Int) {}
+ override fun onBadgingChanged(sessionId: Int) {}
+ override fun onActiveChanged(sessionId: Int, active: Boolean) {}
+ override fun onProgressChanged(sessionId: Int, progress: Float) {}
+ override fun onFinished(sessionId: Int, success: Boolean) {
+ if (sessionId == id) cont.resume(true)
+ }
+ }
+ installerCallbacks.add(installerCallback)
+
+ sessionInstaller.registerSessionCallback(
+ installerCallbacks.last(),
+ Handler(Looper.getMainLooper())
+ )
+
+ sessionInstaller.openSession(id).use { activeSession ->
+ val sizeBytes = cacheFile.length()
+ cacheFile.inputStream().use { fileStream ->
+ activeSession.openWrite(cacheFile.name, 0, sizeBytes).use { outputStream ->
+ if (cont.isActive) {
+ fileStream.copyTo(outputStream)
+ activeSession.fsync(outputStream)
+ }
+ }
+ }
+
+ val pendingIntent = PendingIntent.getService(context, id, intent, flags)
+
+ if (cont.isActive) activeSession.commit(pendingIntent.intentSender)
+ }
+ cont.invokeOnCancellation {
+ sessionInstaller.abandonSession(id)
+ }
+ }
+
+ fun cleanup() {
+ installerCallbacks.forEach { sessionInstaller.unregisterSessionCallback(it) }
+ sessionInstaller.mySessions.forEach { sessionInstaller.abandonSession(it.sessionId) }
+ }
+}
\ No newline at end of file
diff --git a/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/SessionInstallerService.kt b/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/SessionInstallerService.kt
new file mode 100644
index 000000000..ce4cb37a7
--- /dev/null
+++ b/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/SessionInstallerService.kt
@@ -0,0 +1,69 @@
+package me.timschneeberger.rootlessjamesdsp.flavor.updates
+
+import android.app.Service
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.os.IBinder
+import me.timschneeberger.rootlessjamesdsp.R
+import me.timschneeberger.rootlessjamesdsp.activity.MainActivity
+import me.timschneeberger.rootlessjamesdsp.utils.Cache
+import me.timschneeberger.rootlessjamesdsp.utils.ContextExtensions.toast
+import me.timschneeberger.rootlessjamesdsp.utils.getParcelableAs
+import timber.log.Timber
+
+/** Based on https://github.com/Iamlooker/Droid-ify/ licensed under GPLv3 */
+class SessionInstallerService : Service() {
+ override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
+ val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
+ val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+ Timber.i("SessionInstallerService: $message ($status)")
+
+ if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
+ // prompts user to enable unknown source
+ val promptIntent: Intent? = intent.extras?.getParcelableAs(Intent.EXTRA_INTENT)
+
+ promptIntent?.let {
+ it.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
+ it.putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, "com.android.vending")
+ it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ startActivity(it)
+ }
+ } else {
+ notifyStatus(intent)
+ }
+
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ /**
+ * Notifies user of installer outcome.
+ */
+ private fun notifyStatus(intent: Intent) {
+ val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
+ val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+
+ when (status) {
+ PackageInstaller.STATUS_SUCCESS -> {
+ toast(getString(R.string.self_update_finished), true)
+
+ Intent(this, MainActivity::class.java)
+ .apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
+ .let(::startActivity)
+ .also { Runtime.getRuntime().exit(0) }
+ }
+ PackageInstaller.STATUS_FAILURE_ABORTED -> {
+ // do nothing if user cancels
+ }
+ else -> {
+ // problem occurred when installing/uninstalling package
+ toast(getString(R.string.self_update_install_fail, message), true)
+ // cleanup possible corrupted packages
+ Cache.cleanupNow(this)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/api/UpdateCheckClient.kt b/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/api/UpdateCheckClient.kt
new file mode 100644
index 000000000..3b9e00af1
--- /dev/null
+++ b/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/api/UpdateCheckClient.kt
@@ -0,0 +1,66 @@
+package me.timschneeberger.rootlessjamesdsp.flavor.updates.api
+
+import android.content.Context
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import me.timschneeberger.rootlessjamesdsp.BuildConfig
+import me.timschneeberger.rootlessjamesdsp.api.UserAgentInterceptor
+import me.timschneeberger.rootlessjamesdsp.flavor.updates.model.UpdateCheckResponse
+import me.timschneeberger.rootlessjamesdsp.utils.ApiExtensions
+import me.timschneeberger.rootlessjamesdsp.utils.ApiExtensions.save
+import me.timschneeberger.rootlessjamesdsp.utils.Cache
+import me.timschneeberger.rootlessjamesdsp.utils.Result
+import okhttp3.OkHttpClient
+import org.koin.core.component.KoinComponent
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import retrofit2.converter.scalars.ScalarsConverterFactory
+import timber.log.Timber
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+class UpdateCheckClient(val context: Context, callTimeout: Long = 10): KoinComponent {
+
+ private val http = OkHttpClient
+ .Builder()
+ .callTimeout(callTimeout, TimeUnit.SECONDS)
+ .addInterceptor(UserAgentInterceptor("RootlessJamesDSP v${BuildConfig.VERSION_NAME}"))
+ .build()
+
+ private val retrofit: Retrofit = Retrofit.Builder()
+ .baseUrl("https://update.timschneeberger.me")
+ .client(http)
+ .addConverterFactory(ScalarsConverterFactory.create())
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+
+ private val service: UpdateCheckService = retrofit.create(UpdateCheckService::class.java)
+
+ suspend fun checkUpdate(): Flow> {
+ return flow> {
+ try {
+ val result =
+ service.checkUpdate(BuildConfig.FLAVOR, BuildConfig.VERSION_CODE.toString())
+ .execute()
+ if(result.isSuccessful)
+ emit(Result.Success(result.body()))
+ else
+ emit(Result.Error(IOException(result.message())))
+ }
+ catch (ex: IOException) {
+ Timber.d(ex)
+ emit(Result.Error(ex))
+ }
+ }
+ .flowOn(Dispatchers.IO)
+ .distinctUntilChanged()
+ }
+
+ suspend fun downloadUpdate(updateInfo: UpdateCheckResponse): Flow {
+ val file = Cache.getReleaseFile(context, "${updateInfo.versionCode.toString()}.apk")
+ return service.downloadUpdate(BuildConfig.FLAVOR).save(file)
+ }
+}
\ No newline at end of file
diff --git a/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/api/UpdateCheckService.kt b/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/api/UpdateCheckService.kt
new file mode 100644
index 000000000..b7672ffb9
--- /dev/null
+++ b/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/api/UpdateCheckService.kt
@@ -0,0 +1,18 @@
+package me.timschneeberger.rootlessjamesdsp.flavor.updates.api
+
+import me.timschneeberger.rootlessjamesdsp.flavor.updates.model.UpdateCheckResponse
+import me.timschneeberger.rootlessjamesdsp.model.api.AeqSearchResult
+import okhttp3.ResponseBody
+import retrofit2.Call
+import retrofit2.http.GET
+import retrofit2.http.Path
+import retrofit2.http.Streaming
+
+interface UpdateCheckService {
+ @GET("updates/check/rootlessjamesdsp/{flavor}/{versionCode}")
+ fun checkUpdate(@Path("flavor") flavor: String, @Path("versionCode") versionCode: String): Call
+
+ @Streaming
+ @GET("updates/download/rootlessjamesdsp/{flavor}")
+ suspend fun downloadUpdate(@Path("flavor") flavor: String): ResponseBody
+}
\ No newline at end of file
diff --git a/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/model/UpdateCheckResponse.kt b/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/model/UpdateCheckResponse.kt
new file mode 100644
index 000000000..43393821f
--- /dev/null
+++ b/app/src/root/java/me/timschneeberger/rootlessjamesdsp/flavor/updates/model/UpdateCheckResponse.kt
@@ -0,0 +1,10 @@
+package me.timschneeberger.rootlessjamesdsp.flavor.updates.model
+
+import com.google.gson.annotations.SerializedName
+import java.io.Serializable
+
+data class UpdateCheckResponse(
+ @SerializedName("name") var name: String? = null,
+ @SerializedName("versionName") var versionName: String? = null,
+ @SerializedName("versionCode") var versionCode: Int? = null
+) : Serializable
diff --git a/app/src/rootless/java/me/timschneeberger/rootlessjamesdsp/flavor/UpdateManager.kt b/app/src/rootless/java/me/timschneeberger/rootlessjamesdsp/flavor/UpdateManager.kt
new file mode 100644
index 000000000..5679bc6fa
--- /dev/null
+++ b/app/src/rootless/java/me/timschneeberger/rootlessjamesdsp/flavor/UpdateManager.kt
@@ -0,0 +1,14 @@
+@file:Suppress("UNUSED_PARAMETER", "RedundantSuspendModifier", "RedundantNullableReturnType")
+
+package me.timschneeberger.rootlessjamesdsp.flavor
+
+import android.content.Context
+import kotlinx.coroutines.flow.Flow
+import me.timschneeberger.rootlessjamesdsp.utils.Result
+import java.lang.IllegalStateException
+
+class UpdateManager(val context: Context) {
+ fun getUpdateVersionInfo(): Pair? = throw IllegalStateException()
+ suspend fun isUpdateAvailable(): Flow> = throw IllegalStateException()
+ fun installUpdate(context: Context): Nothing = throw IllegalStateException()
+}