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() +}