v0.6.5
This commit is contained in:
267
app/src/main/kotlin/com/looker/droidify/Droidify.kt
Normal file
267
app/src/main/kotlin/com/looker/droidify/Droidify.kt
Normal file
@@ -0,0 +1,267 @@
|
||||
package com.looker.droidify
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.NetworkType
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.disk.directory
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.crossfade
|
||||
import com.looker.droidify.content.ProductPreferences
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.get
|
||||
import com.looker.droidify.datastore.model.AutoSync
|
||||
import com.looker.droidify.datastore.model.ProxyPreference
|
||||
import com.looker.droidify.datastore.model.ProxyType
|
||||
import com.looker.droidify.index.RepositoryUpdater
|
||||
import com.looker.droidify.installer.InstallManager
|
||||
import com.looker.droidify.network.Downloader
|
||||
import com.looker.droidify.receivers.InstalledAppReceiver
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import com.looker.droidify.sync.SyncPreference
|
||||
import com.looker.droidify.sync.toJobNetworkType
|
||||
import com.looker.droidify.utility.common.Constants
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.extension.getDrawableCompat
|
||||
import com.looker.droidify.utility.common.extension.getInstalledPackagesCompat
|
||||
import com.looker.droidify.utility.common.extension.jobScheduler
|
||||
import com.looker.droidify.utility.common.log
|
||||
import com.looker.droidify.utility.extension.toInstalledItem
|
||||
import com.looker.droidify.work.CleanUpWorker
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.collectIndexed
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.INFINITE
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
@HiltAndroidApp
|
||||
class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Provider {
|
||||
|
||||
private val parentJob = SupervisorJob()
|
||||
private val appScope = CoroutineScope(Dispatchers.Default + parentJob)
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var installer: InstallManager
|
||||
|
||||
@Inject
|
||||
lateinit var downloader: Downloader
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
if (BuildConfig.DEBUG && SdkCheck.isOreo) strictThreadPolicy()
|
||||
|
||||
val databaseUpdated = Database.init(this)
|
||||
ProductPreferences.init(this, appScope)
|
||||
RepositoryUpdater.init(appScope, downloader)
|
||||
listenApplications()
|
||||
checkLanguage()
|
||||
updatePreference()
|
||||
appScope.launch { installer() }
|
||||
|
||||
if (databaseUpdated) forceSyncAll()
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
appScope.cancel("Application Terminated")
|
||||
installer.close()
|
||||
}
|
||||
|
||||
private fun listenApplications() {
|
||||
appScope.launch(Dispatchers.Default) {
|
||||
registerReceiver(
|
||||
InstalledAppReceiver(packageManager),
|
||||
IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
)
|
||||
val installedItems =
|
||||
packageManager.getInstalledPackagesCompat()
|
||||
?.map { it.toInstalledItem() }
|
||||
?: return@launch
|
||||
Database.InstalledAdapter.putAll(installedItems)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkLanguage() {
|
||||
appScope.launch {
|
||||
val lastSetLanguage = settingsRepository.getInitial().language
|
||||
val systemSetLanguage = AppCompatDelegate.getApplicationLocales().toLanguageTags()
|
||||
if (systemSetLanguage != lastSetLanguage && lastSetLanguage != "system") {
|
||||
settingsRepository.setLanguage(systemSetLanguage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePreference() {
|
||||
appScope.launch {
|
||||
launch {
|
||||
settingsRepository.get { unstableUpdate }.drop(1).collect {
|
||||
forceSyncAll()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
settingsRepository.get { autoSync }.collectIndexed { index, syncMode ->
|
||||
// Don't update sync job on initial collect
|
||||
updateSyncJob(index > 0, syncMode)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
settingsRepository.get { cleanUpInterval }.drop(1).collect {
|
||||
if (it == INFINITE) {
|
||||
CleanUpWorker.removeAllSchedules(applicationContext)
|
||||
} else {
|
||||
CleanUpWorker.scheduleCleanup(applicationContext, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
settingsRepository.get { proxy }.collect(::updateProxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProxy(proxyPreference: ProxyPreference) {
|
||||
val type = proxyPreference.type
|
||||
val host = proxyPreference.host
|
||||
val port = proxyPreference.port
|
||||
val socketAddress = when (type) {
|
||||
ProxyType.DIRECT -> null
|
||||
ProxyType.HTTP, ProxyType.SOCKS -> {
|
||||
try {
|
||||
InetSocketAddress.createUnresolved(host, port)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
log(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
val androidProxyType = when (type) {
|
||||
ProxyType.DIRECT -> Proxy.Type.DIRECT
|
||||
ProxyType.HTTP -> Proxy.Type.HTTP
|
||||
ProxyType.SOCKS -> Proxy.Type.SOCKS
|
||||
}
|
||||
val determinedProxy = socketAddress?.let { Proxy(androidProxyType, it) } ?: Proxy.NO_PROXY
|
||||
downloader.setProxy(determinedProxy)
|
||||
}
|
||||
|
||||
private fun updateSyncJob(force: Boolean, autoSync: AutoSync) {
|
||||
if (autoSync == AutoSync.NEVER) {
|
||||
jobScheduler?.cancel(Constants.JOB_ID_SYNC)
|
||||
return
|
||||
}
|
||||
val jobScheduler = jobScheduler
|
||||
val syncConditions = when (autoSync) {
|
||||
AutoSync.ALWAYS -> SyncPreference(NetworkType.CONNECTED)
|
||||
AutoSync.WIFI_ONLY -> SyncPreference(NetworkType.UNMETERED)
|
||||
AutoSync.WIFI_PLUGGED_IN -> SyncPreference(NetworkType.UNMETERED, pluggedIn = true)
|
||||
else -> null
|
||||
}
|
||||
val isCompleted = jobScheduler?.allPendingJobs
|
||||
?.any { it.id == Constants.JOB_ID_SYNC } == false
|
||||
if ((force || isCompleted) && syncConditions != null) {
|
||||
val period = 12.hours.inWholeMilliseconds
|
||||
val job = SyncService.Job.create(
|
||||
context = this,
|
||||
periodMillis = period,
|
||||
networkType = syncConditions.toJobNetworkType(),
|
||||
isCharging = syncConditions.pluggedIn,
|
||||
isBatteryLow = syncConditions.batteryNotLow
|
||||
)
|
||||
jobScheduler?.schedule(job)
|
||||
}
|
||||
}
|
||||
|
||||
private fun forceSyncAll() {
|
||||
Database.RepositoryAdapter.getAll().forEach {
|
||||
if (it.lastModified.isNotEmpty() || it.entityTag.isNotEmpty()) {
|
||||
Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = ""))
|
||||
}
|
||||
}
|
||||
Connection(SyncService::class.java, onBind = { connection, binder ->
|
||||
binder.sync(SyncService.SyncRequest.FORCE)
|
||||
connection.unbind(this)
|
||||
}).bind(this)
|
||||
}
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
@SuppressLint("UnsafeProtectedBroadcastReceiver")
|
||||
override fun onReceive(context: Context, intent: Intent) = Unit
|
||||
}
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
||||
val memoryCache = MemoryCache.Builder()
|
||||
.maxSizePercent(context, 0.25)
|
||||
.build()
|
||||
|
||||
val diskCache = DiskCache.Builder()
|
||||
.directory(Cache.getImagesDir(this))
|
||||
.maxSizePercent(0.05)
|
||||
.build()
|
||||
|
||||
return ImageLoader.Builder(this)
|
||||
.memoryCache(memoryCache)
|
||||
.diskCache(diskCache)
|
||||
.error(getDrawableCompat(R.drawable.ic_cannot_load).asImage())
|
||||
.crossfade(350)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun strictThreadPolicy() {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectDiskReads()
|
||||
.detectDiskWrites()
|
||||
.detectNetwork()
|
||||
.detectUnbufferedIo()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
307
app/src/main/kotlin/com/looker/droidify/MainActivity.kt
Normal file
307
app/src/main/kotlin/com/looker/droidify/MainActivity.kt
Normal file
@@ -0,0 +1,307 @@
|
||||
package com.looker.droidify
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.looker.droidify.utility.common.DeeplinkType
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.deeplinkType
|
||||
import com.looker.droidify.utility.common.extension.homeAsUp
|
||||
import com.looker.droidify.utility.common.extension.inputManager
|
||||
import com.looker.droidify.utility.common.getInstallPackageName
|
||||
import com.looker.droidify.utility.common.requestNotificationPermission
|
||||
import com.looker.droidify.database.CursorOwner
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.extension.getThemeRes
|
||||
import com.looker.droidify.datastore.get
|
||||
import com.looker.droidify.installer.InstallManager
|
||||
import com.looker.droidify.installer.model.installFrom
|
||||
import com.looker.droidify.ui.appDetail.AppDetailFragment
|
||||
import com.looker.droidify.ui.favourites.FavouritesFragment
|
||||
import com.looker.droidify.ui.repository.EditRepositoryFragment
|
||||
import com.looker.droidify.ui.repository.RepositoriesFragment
|
||||
import com.looker.droidify.ui.repository.RepositoryFragment
|
||||
import com.looker.droidify.ui.settings.SettingsFragment
|
||||
import com.looker.droidify.ui.tabsFragment.TabsFragment
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private const val STATE_FRAGMENT_STACK = "fragmentStack"
|
||||
const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES"
|
||||
const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
|
||||
const val EXTRA_CACHE_FILE_NAME =
|
||||
"${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
|
||||
}
|
||||
|
||||
private val notificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
@Inject
|
||||
lateinit var installer: InstallManager
|
||||
|
||||
@Parcelize
|
||||
private class FragmentStackItem(
|
||||
val className: String, val arguments: Bundle?, val savedState: Fragment.SavedState?
|
||||
) : Parcelable
|
||||
|
||||
lateinit var cursorOwner: CursorOwner
|
||||
private set
|
||||
|
||||
private var onBackPressedCallback: OnBackPressedCallback? = null
|
||||
|
||||
private val fragmentStack = mutableListOf<FragmentStackItem>()
|
||||
|
||||
private val currentFragment: Fragment?
|
||||
get() {
|
||||
supportFragmentManager.executePendingTransactions()
|
||||
return supportFragmentManager.findFragmentById(R.id.main_content)
|
||||
}
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface CustomUserRepositoryInjector {
|
||||
fun settingsRepository(): SettingsRepository
|
||||
}
|
||||
|
||||
private fun collectChange() {
|
||||
val hiltEntryPoint = EntryPointAccessors.fromApplication(
|
||||
this, CustomUserRepositoryInjector::class.java
|
||||
)
|
||||
val newSettings = hiltEntryPoint.settingsRepository().get { theme to dynamicTheme }
|
||||
runBlocking {
|
||||
val theme = newSettings.first()
|
||||
setTheme(
|
||||
resources.configuration.getThemeRes(
|
||||
theme = theme.first, dynamicTheme = theme.second
|
||||
)
|
||||
)
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
newSettings.drop(1).collect { themeAndDynamic ->
|
||||
setTheme(
|
||||
resources.configuration.getThemeRes(
|
||||
theme = themeAndDynamic.first, dynamicTheme = themeAndDynamic.second
|
||||
)
|
||||
)
|
||||
recreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
collectChange()
|
||||
super.onCreate(savedInstanceState)
|
||||
val rootView = FrameLayout(this).apply { id = R.id.main_content }
|
||||
addContentView(
|
||||
rootView, ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
|
||||
requestNotificationPermission(request = notificationPermission::launch)
|
||||
|
||||
supportFragmentManager.addFragmentOnAttachListener { _, _ ->
|
||||
hideKeyboard()
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
cursorOwner = CursorOwner()
|
||||
supportFragmentManager.commit {
|
||||
add(cursorOwner, CursorOwner::class.java.name)
|
||||
}
|
||||
} else {
|
||||
cursorOwner =
|
||||
supportFragmentManager.findFragmentByTag(CursorOwner::class.java.name) as CursorOwner
|
||||
}
|
||||
|
||||
savedInstanceState?.getParcelableArrayList<FragmentStackItem>(STATE_FRAGMENT_STACK)
|
||||
?.let { fragmentStack += it }
|
||||
if (savedInstanceState == null) {
|
||||
replaceFragment(TabsFragment(), null)
|
||||
if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) {
|
||||
handleIntent(intent)
|
||||
}
|
||||
}
|
||||
if (SdkCheck.isR) {
|
||||
window.statusBarColor = resources.getColor(android.R.color.transparent, theme)
|
||||
window.navigationBarColor = resources.getColor(android.R.color.transparent, theme)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
}
|
||||
backHandler()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
onBackPressedCallback = null
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putParcelableArrayList(STATE_FRAGMENT_STACK, ArrayList(fragmentStack))
|
||||
}
|
||||
|
||||
private fun backHandler() {
|
||||
if (onBackPressedCallback == null) {
|
||||
onBackPressedCallback = object : OnBackPressedCallback(enabled = false) {
|
||||
override fun handleOnBackPressed() {
|
||||
hideKeyboard()
|
||||
popFragment()
|
||||
}
|
||||
}
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
onBackPressedCallback!!,
|
||||
)
|
||||
}
|
||||
onBackPressedCallback?.isEnabled = fragmentStack.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun replaceFragment(fragment: Fragment, open: Boolean?) {
|
||||
if (open != null) {
|
||||
currentFragment?.view?.translationZ =
|
||||
(if (open) Int.MIN_VALUE else Int.MAX_VALUE).toFloat()
|
||||
}
|
||||
supportFragmentManager.commit {
|
||||
if (open != null) {
|
||||
setCustomAnimations(
|
||||
if (open) R.animator.slide_in else 0,
|
||||
if (open) R.animator.slide_in_keep else R.animator.slide_out
|
||||
)
|
||||
}
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.main_content, fragment)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pushFragment(fragment: Fragment) {
|
||||
currentFragment?.let {
|
||||
fragmentStack.add(
|
||||
FragmentStackItem(
|
||||
it::class.java.name,
|
||||
it.arguments,
|
||||
supportFragmentManager.saveFragmentInstanceState(it)
|
||||
)
|
||||
)
|
||||
}
|
||||
replaceFragment(fragment, true)
|
||||
backHandler()
|
||||
}
|
||||
|
||||
private fun popFragment(): Boolean {
|
||||
return fragmentStack.isNotEmpty() && run {
|
||||
val stackItem = fragmentStack.removeAt(fragmentStack.size - 1)
|
||||
val fragment = Class.forName(stackItem.className).newInstance() as Fragment
|
||||
stackItem.arguments?.let(fragment::setArguments)
|
||||
stackItem.savedState?.let(fragment::setInitialSavedState)
|
||||
replaceFragment(fragment, false)
|
||||
backHandler()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideKeyboard() {
|
||||
inputManager?.hideSoftInputFromWindow((currentFocus ?: window.decorView).windowToken, 0)
|
||||
}
|
||||
|
||||
internal fun onToolbarCreated(toolbar: Toolbar) {
|
||||
if (fragmentStack.isNotEmpty()) {
|
||||
toolbar.navigationIcon = toolbar.context.homeAsUp
|
||||
toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
ACTION_UPDATES -> {
|
||||
if (currentFragment !is TabsFragment) {
|
||||
fragmentStack.clear()
|
||||
replaceFragment(TabsFragment(), true)
|
||||
}
|
||||
val tabsFragment = currentFragment as TabsFragment
|
||||
tabsFragment.selectUpdates()
|
||||
backHandler()
|
||||
}
|
||||
|
||||
ACTION_INSTALL -> {
|
||||
val packageName = intent.getInstallPackageName
|
||||
if (!packageName.isNullOrEmpty()) {
|
||||
navigateProduct(packageName)
|
||||
val cacheFile = intent.getStringExtra(EXTRA_CACHE_FILE_NAME) ?: return
|
||||
val installItem = packageName installFrom cacheFile
|
||||
lifecycleScope.launch { installer install installItem }
|
||||
}
|
||||
}
|
||||
|
||||
Intent.ACTION_VIEW -> {
|
||||
when (val deeplink = intent.deeplinkType) {
|
||||
is DeeplinkType.AppDetail -> {
|
||||
val fragment = currentFragment
|
||||
if (fragment !is AppDetailFragment) {
|
||||
navigateProduct(deeplink.packageName, deeplink.repoAddress)
|
||||
}
|
||||
}
|
||||
|
||||
is DeeplinkType.AddRepository -> {
|
||||
navigateAddRepository(repoAddress = deeplink.address)
|
||||
}
|
||||
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
Intent.ACTION_SHOW_APP_INFO -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val packageName = intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME)
|
||||
|
||||
if (packageName != null && currentFragment !is AppDetailFragment) {
|
||||
navigateProduct(packageName)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateFavourites() = pushFragment(FavouritesFragment())
|
||||
fun navigateProduct(packageName: String, repoAddress: String? = null) =
|
||||
pushFragment(AppDetailFragment(packageName, repoAddress))
|
||||
|
||||
fun navigateRepositories() = pushFragment(RepositoriesFragment())
|
||||
fun navigatePreferences() = pushFragment(SettingsFragment.newInstance())
|
||||
fun navigateAddRepository(repoAddress: String? = null) =
|
||||
pushFragment(EditRepositoryFragment(null, repoAddress))
|
||||
|
||||
fun navigateRepository(repositoryId: Long) =
|
||||
pushFragment(RepositoryFragment(repositoryId))
|
||||
|
||||
fun navigateEditRepository(repositoryId: Long) =
|
||||
pushFragment(EditRepositoryFragment(repositoryId, null))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.looker.droidify.content
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.looker.droidify.utility.common.extension.Json
|
||||
import com.looker.droidify.utility.common.extension.parseDictionary
|
||||
import com.looker.droidify.utility.common.extension.writeDictionary
|
||||
import com.looker.droidify.model.ProductPreference
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.utility.serialization.productPreference
|
||||
import com.looker.droidify.utility.serialization.serialize
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.charset.Charset
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object ProductPreferences {
|
||||
private val defaultProductPreference = ProductPreference(false, 0L)
|
||||
private lateinit var preferences: SharedPreferences
|
||||
private val mutableSubject = MutableSharedFlow<Pair<String, Long?>>()
|
||||
private val subject = mutableSubject.asSharedFlow()
|
||||
|
||||
fun init(context: Context, scope: CoroutineScope) {
|
||||
preferences = context.getSharedPreferences("product_preferences", Context.MODE_PRIVATE)
|
||||
Database.LockAdapter.putAll(
|
||||
preferences.all.keys.mapNotNull { packageName ->
|
||||
this[packageName].databaseVersionCode?.let { Pair(packageName, it) }
|
||||
}
|
||||
)
|
||||
scope.launch {
|
||||
subject.collect { (packageName, versionCode) ->
|
||||
if (versionCode != null) {
|
||||
Database.LockAdapter.put(Pair(packageName, versionCode))
|
||||
} else {
|
||||
Database.LockAdapter.delete(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val ProductPreference.databaseVersionCode: Long?
|
||||
get() = when {
|
||||
ignoreUpdates -> 0L
|
||||
ignoreVersionCode > 0L -> ignoreVersionCode
|
||||
else -> null
|
||||
}
|
||||
|
||||
operator fun get(packageName: String): ProductPreference {
|
||||
return if (preferences.contains(packageName)) {
|
||||
try {
|
||||
Json.factory.createParser(preferences.getString(packageName, "{}"))
|
||||
.use { it.parseDictionary { productPreference() } }
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
defaultProductPreference
|
||||
}
|
||||
} else {
|
||||
defaultProductPreference
|
||||
}
|
||||
}
|
||||
|
||||
operator fun set(packageName: String, productPreference: ProductPreference) {
|
||||
val oldProductPreference = this[packageName]
|
||||
preferences.edit().putString(
|
||||
packageName,
|
||||
ByteArrayOutputStream().apply {
|
||||
Json.factory.createGenerator(this)
|
||||
.use { it.writeDictionary(productPreference::serialize) }
|
||||
}.toByteArray().toString(Charset.defaultCharset())
|
||||
).apply()
|
||||
if (oldProductPreference.ignoreUpdates != productPreference.ignoreUpdates ||
|
||||
oldProductPreference.ignoreVersionCode != productPreference.ignoreVersionCode
|
||||
) {
|
||||
mutableSubject.tryEmit(Pair(packageName, productPreference.databaseVersionCode))
|
||||
}
|
||||
}
|
||||
}
|
||||
145
app/src/main/kotlin/com/looker/droidify/database/CursorOwner.kt
Normal file
145
app/src/main/kotlin/com/looker/droidify/database/CursorOwner.kt
Normal file
@@ -0,0 +1,145 @@
|
||||
package com.looker.droidify.database
|
||||
|
||||
import android.database.Cursor
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.loader.app.LoaderManager
|
||||
import androidx.loader.content.Loader
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.droidify.model.ProductItem
|
||||
|
||||
class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
sealed class Request {
|
||||
internal abstract val id: Int
|
||||
|
||||
class Available(
|
||||
val searchQuery: String,
|
||||
val section: ProductItem.Section,
|
||||
val order: SortOrder,
|
||||
) : Request() {
|
||||
override val id: Int
|
||||
get() = 1
|
||||
}
|
||||
|
||||
class Installed(
|
||||
val searchQuery: String,
|
||||
val section: ProductItem.Section,
|
||||
val order: SortOrder,
|
||||
) : Request() {
|
||||
override val id: Int
|
||||
get() = 2
|
||||
}
|
||||
|
||||
class Updates(
|
||||
val searchQuery: String,
|
||||
val section: ProductItem.Section,
|
||||
val order: SortOrder,
|
||||
val skipSignatureCheck: Boolean,
|
||||
) : Request() {
|
||||
override val id: Int
|
||||
get() = 3
|
||||
}
|
||||
|
||||
object Repositories : Request() {
|
||||
override val id: Int
|
||||
get() = 4
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onCursorData(request: Request, cursor: Cursor?)
|
||||
}
|
||||
|
||||
private data class ActiveRequest(
|
||||
val request: Request,
|
||||
val callback: Callback?,
|
||||
val cursor: Cursor?,
|
||||
)
|
||||
|
||||
init {
|
||||
retainInstance = true
|
||||
}
|
||||
|
||||
private val activeRequests = mutableMapOf<Int, ActiveRequest>()
|
||||
|
||||
fun attach(callback: Callback, request: Request) {
|
||||
val oldActiveRequest = activeRequests[request.id]
|
||||
if (oldActiveRequest?.callback != null &&
|
||||
oldActiveRequest.callback != callback && oldActiveRequest.cursor != null
|
||||
) {
|
||||
oldActiveRequest.callback.onCursorData(oldActiveRequest.request, null)
|
||||
}
|
||||
val cursor = if (oldActiveRequest?.request == request && oldActiveRequest.cursor != null) {
|
||||
callback.onCursorData(request, oldActiveRequest.cursor)
|
||||
oldActiveRequest.cursor
|
||||
} else {
|
||||
null
|
||||
}
|
||||
activeRequests[request.id] = ActiveRequest(request, callback, cursor)
|
||||
if (cursor == null) {
|
||||
LoaderManager.getInstance(this).restartLoader(request.id, null, this)
|
||||
}
|
||||
}
|
||||
|
||||
fun detach(callback: Callback) {
|
||||
for (id in activeRequests.keys) {
|
||||
val activeRequest = activeRequests[id]!!
|
||||
if (activeRequest.callback == callback) {
|
||||
activeRequests[id] = activeRequest.copy(callback = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
|
||||
val request = activeRequests[id]!!.request
|
||||
return QueryLoader(requireContext()) {
|
||||
when (request) {
|
||||
is Request.Available ->
|
||||
Database.ProductAdapter
|
||||
.query(
|
||||
installed = false,
|
||||
updates = false,
|
||||
searchQuery = request.searchQuery,
|
||||
section = request.section,
|
||||
order = request.order,
|
||||
signal = it,
|
||||
)
|
||||
|
||||
is Request.Installed ->
|
||||
Database.ProductAdapter
|
||||
.query(
|
||||
installed = true,
|
||||
updates = false,
|
||||
searchQuery = request.searchQuery,
|
||||
section = request.section,
|
||||
order = request.order,
|
||||
signal = it,
|
||||
)
|
||||
|
||||
is Request.Updates ->
|
||||
Database.ProductAdapter
|
||||
.query(
|
||||
installed = true,
|
||||
updates = true,
|
||||
searchQuery = request.searchQuery,
|
||||
section = request.section,
|
||||
order = request.order,
|
||||
signal = it,
|
||||
skipSignatureCheck = request.skipSignatureCheck,
|
||||
)
|
||||
|
||||
is Request.Repositories -> Database.RepositoryAdapter.query(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
|
||||
val activeRequest = activeRequests[loader.id]
|
||||
if (activeRequest != null) {
|
||||
activeRequests[loader.id] = activeRequest.copy(cursor = data)
|
||||
activeRequest.callback?.onCursorData(activeRequest.request, data)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Cursor>) = onLoadFinished(loader, null)
|
||||
}
|
||||
982
app/src/main/kotlin/com/looker/droidify/database/Database.kt
Normal file
982
app/src/main/kotlin/com/looker/droidify/database/Database.kt
Normal file
@@ -0,0 +1,982 @@
|
||||
package com.looker.droidify.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import android.os.CancellationSignal
|
||||
import androidx.core.database.sqlite.transaction
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.droidify.model.InstalledItem
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.utility.common.extension.Json
|
||||
import com.looker.droidify.utility.common.extension.asSequence
|
||||
import com.looker.droidify.utility.common.extension.firstOrNull
|
||||
import com.looker.droidify.utility.common.extension.parseDictionary
|
||||
import com.looker.droidify.utility.common.extension.writeDictionary
|
||||
import com.looker.droidify.utility.common.log
|
||||
import com.looker.droidify.utility.serialization.product
|
||||
import com.looker.droidify.utility.serialization.productItem
|
||||
import com.looker.droidify.utility.serialization.repository
|
||||
import com.looker.droidify.utility.serialization.serialize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.collections.component1
|
||||
import kotlin.collections.component2
|
||||
import kotlin.collections.set
|
||||
|
||||
object Database {
|
||||
fun init(context: Context): Boolean {
|
||||
val helper = Helper(context)
|
||||
db = helper.writableDatabase
|
||||
if (helper.created) {
|
||||
for (repository in Repository.defaultRepositories.sortedBy { it.name }) {
|
||||
RepositoryAdapter.put(repository)
|
||||
}
|
||||
}
|
||||
RepositoryAdapter.removeDuplicates()
|
||||
return helper.created || helper.updated
|
||||
}
|
||||
|
||||
private lateinit var db: SQLiteDatabase
|
||||
|
||||
private interface Table {
|
||||
val memory: Boolean
|
||||
val innerName: String
|
||||
val createTable: String
|
||||
val createIndex: String?
|
||||
get() = null
|
||||
|
||||
val databasePrefix: String
|
||||
get() = if (memory) "memory." else ""
|
||||
|
||||
val name: String
|
||||
get() = "$databasePrefix$innerName"
|
||||
|
||||
fun formatCreateTable(name: String): String {
|
||||
return buildString(128) {
|
||||
append("CREATE TABLE ")
|
||||
append(name)
|
||||
append(" (")
|
||||
trimAndJoin(createTable)
|
||||
append(")")
|
||||
}
|
||||
}
|
||||
|
||||
val createIndexPairFormatted: Pair<String, String>?
|
||||
get() = createIndex?.let {
|
||||
Pair(
|
||||
"CREATE INDEX ${innerName}_index ON $innerName ($it)",
|
||||
"CREATE INDEX ${name}_index ON $innerName ($it)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private object Schema {
|
||||
object Repository : Table {
|
||||
const val ROW_ID = "_id"
|
||||
const val ROW_ENABLED = "enabled"
|
||||
const val ROW_DELETED = "deleted"
|
||||
const val ROW_DATA = "data"
|
||||
|
||||
override val memory = false
|
||||
override val innerName = "repository"
|
||||
override val createTable = """
|
||||
$ROW_ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$ROW_ENABLED INTEGER NOT NULL,
|
||||
$ROW_DELETED INTEGER NOT NULL,
|
||||
$ROW_DATA BLOB NOT NULL
|
||||
"""
|
||||
}
|
||||
|
||||
object Product : Table {
|
||||
const val ROW_REPOSITORY_ID = "repository_id"
|
||||
const val ROW_PACKAGE_NAME = "package_name"
|
||||
const val ROW_NAME = "name"
|
||||
const val ROW_SUMMARY = "summary"
|
||||
const val ROW_DESCRIPTION = "description"
|
||||
const val ROW_ADDED = "added"
|
||||
const val ROW_UPDATED = "updated"
|
||||
const val ROW_VERSION_CODE = "version_code"
|
||||
const val ROW_SIGNATURES = "signatures"
|
||||
const val ROW_COMPATIBLE = "compatible"
|
||||
const val ROW_DATA = "data"
|
||||
const val ROW_DATA_ITEM = "data_item"
|
||||
|
||||
override val memory = false
|
||||
override val innerName = "product"
|
||||
override val createTable = """
|
||||
$ROW_REPOSITORY_ID INTEGER NOT NULL,
|
||||
$ROW_PACKAGE_NAME TEXT NOT NULL,
|
||||
$ROW_NAME TEXT NOT NULL,
|
||||
$ROW_SUMMARY TEXT NOT NULL,
|
||||
$ROW_DESCRIPTION TEXT NOT NULL,
|
||||
$ROW_ADDED INTEGER NOT NULL,
|
||||
$ROW_UPDATED INTEGER NOT NULL,
|
||||
$ROW_VERSION_CODE INTEGER NOT NULL,
|
||||
$ROW_SIGNATURES TEXT NOT NULL,
|
||||
$ROW_COMPATIBLE INTEGER NOT NULL,
|
||||
$ROW_DATA BLOB NOT NULL,
|
||||
$ROW_DATA_ITEM BLOB NOT NULL,
|
||||
PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME)
|
||||
"""
|
||||
override val createIndex = ROW_PACKAGE_NAME
|
||||
}
|
||||
|
||||
object Category : Table {
|
||||
const val ROW_REPOSITORY_ID = "repository_id"
|
||||
const val ROW_PACKAGE_NAME = "package_name"
|
||||
const val ROW_NAME = "name"
|
||||
|
||||
override val memory = false
|
||||
override val innerName = "category"
|
||||
override val createTable = """
|
||||
$ROW_REPOSITORY_ID INTEGER NOT NULL,
|
||||
$ROW_PACKAGE_NAME TEXT NOT NULL,
|
||||
$ROW_NAME TEXT NOT NULL,
|
||||
PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME, $ROW_NAME)
|
||||
"""
|
||||
override val createIndex = "$ROW_PACKAGE_NAME, $ROW_NAME"
|
||||
}
|
||||
|
||||
object Installed : Table {
|
||||
const val ROW_PACKAGE_NAME = "package_name"
|
||||
const val ROW_VERSION = "version"
|
||||
const val ROW_VERSION_CODE = "version_code"
|
||||
const val ROW_SIGNATURE = "signature"
|
||||
|
||||
override val memory = true
|
||||
override val innerName = "installed"
|
||||
override val createTable = """
|
||||
$ROW_PACKAGE_NAME TEXT PRIMARY KEY,
|
||||
$ROW_VERSION TEXT NOT NULL,
|
||||
$ROW_VERSION_CODE INTEGER NOT NULL,
|
||||
$ROW_SIGNATURE TEXT NOT NULL
|
||||
"""
|
||||
}
|
||||
|
||||
object Lock : Table {
|
||||
const val ROW_PACKAGE_NAME = "package_name"
|
||||
const val ROW_VERSION_CODE = "version_code"
|
||||
|
||||
override val memory = true
|
||||
override val innerName = "lock"
|
||||
override val createTable = """
|
||||
$ROW_PACKAGE_NAME TEXT PRIMARY KEY,
|
||||
$ROW_VERSION_CODE INTEGER NOT NULL
|
||||
"""
|
||||
}
|
||||
|
||||
object Synthetic {
|
||||
const val ROW_CAN_UPDATE = "can_update"
|
||||
const val ROW_MATCH_RANK = "match_rank"
|
||||
}
|
||||
}
|
||||
|
||||
private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 5) {
|
||||
var created = false
|
||||
private set
|
||||
var updated = false
|
||||
private set
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) = Unit
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
|
||||
onVersionChange(db)
|
||||
|
||||
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
|
||||
onVersionChange(db)
|
||||
|
||||
private fun onVersionChange(db: SQLiteDatabase) {
|
||||
handleTables(db, true, Schema.Product, Schema.Category)
|
||||
addRepos(db, Repository.newlyAdded)
|
||||
this.updated = true
|
||||
}
|
||||
|
||||
override fun onOpen(db: SQLiteDatabase) {
|
||||
val create = handleTables(db, false, Schema.Repository)
|
||||
val updated = handleTables(db, create, Schema.Product, Schema.Category)
|
||||
db.execSQL("ATTACH DATABASE ':memory:' AS memory")
|
||||
handleTables(db, false, Schema.Installed, Schema.Lock)
|
||||
handleIndexes(
|
||||
db,
|
||||
Schema.Repository,
|
||||
Schema.Product,
|
||||
Schema.Category,
|
||||
Schema.Installed,
|
||||
Schema.Lock,
|
||||
)
|
||||
dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category)
|
||||
this.created = this.created || create
|
||||
this.updated = this.updated || create || updated
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean {
|
||||
val shouldRecreate = recreate || tables.any { table ->
|
||||
val sql = db.query(
|
||||
"${table.databasePrefix}sqlite_master",
|
||||
columns = arrayOf("sql"),
|
||||
selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName)),
|
||||
).use { it.firstOrNull()?.getString(0) }.orEmpty()
|
||||
table.formatCreateTable(table.innerName) != sql
|
||||
}
|
||||
return shouldRecreate && run {
|
||||
val shouldVacuum = tables.map {
|
||||
db.execSQL("DROP TABLE IF EXISTS ${it.name}")
|
||||
db.execSQL(it.formatCreateTable(it.name))
|
||||
!it.memory
|
||||
}
|
||||
if (shouldVacuum.any { it } && !db.inTransaction()) {
|
||||
db.execSQL("VACUUM")
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun addRepos(db: SQLiteDatabase, repos: List<Repository>) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
log("Add Repos: $repos", "RepositoryAdapter")
|
||||
}
|
||||
if (repos.isEmpty()) return
|
||||
db.transaction {
|
||||
repos.forEach {
|
||||
RepositoryAdapter.put(it, database = this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) {
|
||||
val shouldVacuum = tables.map { table ->
|
||||
val sqls = db.query(
|
||||
"${table.databasePrefix}sqlite_master",
|
||||
columns = arrayOf("name", "sql"),
|
||||
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName)),
|
||||
)
|
||||
.use { cursor ->
|
||||
cursor.asSequence()
|
||||
.mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } }
|
||||
.toList()
|
||||
}
|
||||
.filter { !it.first.startsWith("sqlite_") }
|
||||
val createIndexes = table.createIndexPairFormatted?.let { listOf(it) }.orEmpty()
|
||||
createIndexes.map { it.first } != sqls.map { it.second } && run {
|
||||
for (name in sqls.map { it.first }) {
|
||||
db.execSQL("DROP INDEX IF EXISTS $name")
|
||||
}
|
||||
for (createIndexPair in createIndexes) {
|
||||
db.execSQL(createIndexPair.second)
|
||||
}
|
||||
!table.memory
|
||||
}
|
||||
}
|
||||
if (shouldVacuum.any { it } && !db.inTransaction()) {
|
||||
db.execSQL("VACUUM")
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) {
|
||||
val tables = db.query(
|
||||
"sqlite_master",
|
||||
columns = arrayOf("name"),
|
||||
selection = Pair("type = ?", arrayOf("table")),
|
||||
)
|
||||
.use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() }
|
||||
.filter { !it.startsWith("sqlite_") && !it.startsWith("android_") }
|
||||
.toSet() - neededTables.mapNotNull { if (it.memory) null else it.name }.toSet()
|
||||
if (tables.isNotEmpty()) {
|
||||
for (table in tables) {
|
||||
db.execSQL("DROP TABLE IF EXISTS $table")
|
||||
}
|
||||
if (!db.inTransaction()) {
|
||||
db.execSQL("VACUUM")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Subject {
|
||||
data object Repositories : Subject()
|
||||
data class Repository(val id: Long) : Subject()
|
||||
data object Products : Subject()
|
||||
}
|
||||
|
||||
private val observers = mutableMapOf<Subject, MutableSet<() -> Unit>>()
|
||||
|
||||
private fun dataObservable(subject: Subject): (Boolean, () -> Unit) -> Unit =
|
||||
{ register, observer ->
|
||||
synchronized(observers) {
|
||||
val set = observers[subject] ?: run {
|
||||
val set = mutableSetOf<() -> Unit>()
|
||||
observers[subject] = set
|
||||
set
|
||||
}
|
||||
if (register) {
|
||||
set += observer
|
||||
} else {
|
||||
set -= observer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun flowCollection(subject: Subject): Flow<Unit> = callbackFlow {
|
||||
val callback: () -> Unit = { trySend(Unit) }
|
||||
val dataObservable = dataObservable(subject)
|
||||
dataObservable(true, callback)
|
||||
|
||||
awaitClose { dataObservable(false, callback) }
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun notifyChanged(vararg subjects: Subject) {
|
||||
synchronized(observers) {
|
||||
subjects.asSequence().mapNotNull { observers[it] }.flatten().forEach { it() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.insertOrReplace(
|
||||
replace: Boolean,
|
||||
table: String,
|
||||
contentValues: ContentValues,
|
||||
): Long {
|
||||
return if (replace) {
|
||||
replace(table, null, contentValues)
|
||||
} else {
|
||||
insert(
|
||||
table,
|
||||
null,
|
||||
contentValues,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.query(
|
||||
table: String,
|
||||
columns: Array<String>? = null,
|
||||
selection: Pair<String, Array<String>>? = null,
|
||||
orderBy: String? = null,
|
||||
signal: CancellationSignal? = null,
|
||||
): Cursor {
|
||||
return query(
|
||||
false,
|
||||
table,
|
||||
columns,
|
||||
selection?.first,
|
||||
selection?.second,
|
||||
null,
|
||||
null,
|
||||
orderBy,
|
||||
null,
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Cursor.observable(subject: Subject): ObservableCursor {
|
||||
return ObservableCursor(this, dataObservable(subject))
|
||||
}
|
||||
|
||||
fun <T> ByteArray.jsonParse(callback: (JsonParser) -> T): T {
|
||||
return Json.factory.createParser(this).use { it.parseDictionary(callback) }
|
||||
}
|
||||
|
||||
fun jsonGenerate(callback: (JsonGenerator) -> Unit): ByteArray {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
Json.factory.createGenerator(outputStream).use { it.writeDictionary(callback) }
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
|
||||
object RepositoryAdapter {
|
||||
internal fun putWithoutNotification(
|
||||
repository: Repository,
|
||||
shouldReplace: Boolean,
|
||||
database: SQLiteDatabase,
|
||||
): Long {
|
||||
return database.insertOrReplace(
|
||||
shouldReplace,
|
||||
Schema.Repository.name,
|
||||
ContentValues().apply {
|
||||
if (shouldReplace) {
|
||||
put(Schema.Repository.ROW_ID, repository.id)
|
||||
}
|
||||
put(Schema.Repository.ROW_ENABLED, if (repository.enabled) 1 else 0)
|
||||
put(Schema.Repository.ROW_DELETED, 0)
|
||||
put(Schema.Repository.ROW_DATA, jsonGenerate(repository::serialize))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun put(repository: Repository, database: SQLiteDatabase = db): Repository {
|
||||
val shouldReplace = repository.id >= 0L
|
||||
val newId = putWithoutNotification(repository, shouldReplace, database)
|
||||
val id = if (shouldReplace) repository.id else newId
|
||||
notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products)
|
||||
return if (newId != repository.id) repository.copy(id = newId) else repository
|
||||
}
|
||||
|
||||
fun removeDuplicates() {
|
||||
db.transaction {
|
||||
val all = getAll()
|
||||
val different = all.distinctBy { it.address }
|
||||
val duplicates = all - different.toSet()
|
||||
duplicates.forEach {
|
||||
markAsDeleted(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getStream(id: Long): Flow<Repository?> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) }
|
||||
.map { get(id) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun get(id: Long): Repository? {
|
||||
return db.query(
|
||||
Schema.Repository.name,
|
||||
selection = Pair(
|
||||
"${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0",
|
||||
arrayOf(id.toString()),
|
||||
),
|
||||
).use { it.firstOrNull()?.let(::transform) }
|
||||
}
|
||||
|
||||
fun getAllStream(): Flow<List<Repository>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) }
|
||||
.map { getAll() }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun getEnabledStream(): Flow<List<Repository>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) }
|
||||
.map { getEnabled() }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
private suspend fun getEnabled(): List<Repository> = withContext(Dispatchers.IO) {
|
||||
db.query(
|
||||
Schema.Repository.name,
|
||||
selection = Pair(
|
||||
"${Schema.Repository.ROW_ENABLED} != 0 AND " +
|
||||
"${Schema.Repository.ROW_DELETED} == 0",
|
||||
emptyArray(),
|
||||
),
|
||||
signal = null,
|
||||
).use { it.asSequence().map(::transform).toList() }
|
||||
}
|
||||
|
||||
fun getAll(): List<Repository> {
|
||||
return db.query(
|
||||
Schema.Repository.name,
|
||||
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
|
||||
signal = null,
|
||||
).use { it.asSequence().map(::transform).toList() }
|
||||
}
|
||||
|
||||
fun getAllRemovedStream(): Flow<Map<Long, Boolean>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) }
|
||||
.map { getAllDisabledDeleted() }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun getAllDisabledDeleted(): Map<Long, Boolean> {
|
||||
return db.query(
|
||||
Schema.Repository.name,
|
||||
columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED),
|
||||
selection = Pair(
|
||||
"${Schema.Repository.ROW_ENABLED} == 0 OR " +
|
||||
"${Schema.Repository.ROW_DELETED} != 0",
|
||||
emptyArray(),
|
||||
),
|
||||
signal = null,
|
||||
).use { parentCursor ->
|
||||
parentCursor.asSequence().associate {
|
||||
val idIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_ID)
|
||||
val isDeletedIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_DELETED)
|
||||
it.getLong(idIndex) to (it.getInt(isDeletedIndex) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsDeleted(id: Long) {
|
||||
db.update(
|
||||
Schema.Repository.name,
|
||||
ContentValues().apply {
|
||||
put(Schema.Repository.ROW_DELETED, 1)
|
||||
},
|
||||
"${Schema.Repository.ROW_ID} = ?",
|
||||
arrayOf(id.toString()),
|
||||
)
|
||||
notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products)
|
||||
}
|
||||
|
||||
fun cleanup(removedRepos: Map<Long, Boolean>) {
|
||||
val result = removedRepos.map { (id, isDeleted) ->
|
||||
val idsString = id.toString()
|
||||
val productsCount = db.delete(
|
||||
Schema.Product.name,
|
||||
"${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)",
|
||||
null,
|
||||
)
|
||||
val categoriesCount = db.delete(
|
||||
Schema.Category.name,
|
||||
"${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)",
|
||||
null,
|
||||
)
|
||||
if (isDeleted) {
|
||||
db.delete(
|
||||
Schema.Repository.name,
|
||||
"${Schema.Repository.ROW_ID} IN ($id)",
|
||||
null,
|
||||
)
|
||||
}
|
||||
productsCount != 0 || categoriesCount != 0
|
||||
}
|
||||
if (result.any { it }) {
|
||||
notifyChanged(Subject.Products)
|
||||
}
|
||||
}
|
||||
|
||||
fun importRepos(list: List<Repository>) {
|
||||
db.transaction {
|
||||
val currentAddresses = getAll().map { it.address }
|
||||
val newRepos = list
|
||||
.filter { it.address !in currentAddresses }
|
||||
newRepos.forEach { put(it) }
|
||||
removeDuplicates()
|
||||
}
|
||||
}
|
||||
|
||||
fun query(signal: CancellationSignal?): Cursor {
|
||||
return db.query(
|
||||
Schema.Repository.name,
|
||||
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
|
||||
orderBy = "${Schema.Repository.ROW_ENABLED} DESC",
|
||||
signal = signal,
|
||||
).observable(Subject.Repositories)
|
||||
}
|
||||
|
||||
fun transform(cursor: Cursor): Repository {
|
||||
return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_DATA))
|
||||
.jsonParse {
|
||||
it.repository().apply {
|
||||
this.id =
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ProductAdapter {
|
||||
|
||||
fun getStream(packageName: String): Flow<List<Product>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
||||
.map { get(packageName, null) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
suspend fun getUpdates(skipSignatureCheck: Boolean): List<ProductItem> =
|
||||
withContext(Dispatchers.IO) {
|
||||
query(
|
||||
installed = true,
|
||||
updates = true,
|
||||
searchQuery = "",
|
||||
skipSignatureCheck = skipSignatureCheck,
|
||||
section = ProductItem.Section.All,
|
||||
order = SortOrder.NAME,
|
||||
signal = null,
|
||||
).use {
|
||||
it.asSequence()
|
||||
.map(ProductAdapter::transformItem)
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun getUpdatesStream(skipSignatureCheck: Boolean): Flow<List<ProductItem>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
||||
// Crashes due to immediate retrieval of data?
|
||||
.onEach { delay(50) }
|
||||
.map { getUpdates(skipSignatureCheck) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun get(packageName: String, signal: CancellationSignal?): List<Product> {
|
||||
return db.query(
|
||||
Schema.Product.name,
|
||||
columns = arrayOf(
|
||||
Schema.Product.ROW_REPOSITORY_ID,
|
||||
Schema.Product.ROW_DESCRIPTION,
|
||||
Schema.Product.ROW_DATA,
|
||||
),
|
||||
selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
|
||||
signal = signal,
|
||||
).use { it.asSequence().map(::transform).toList() }
|
||||
}
|
||||
|
||||
fun getCountStream(repositoryId: Long): Flow<Int> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
||||
.map { getCount(repositoryId) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun getCount(repositoryId: Long): Int {
|
||||
return db.query(
|
||||
Schema.Product.name,
|
||||
columns = arrayOf("COUNT (*)"),
|
||||
selection = Pair(
|
||||
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
|
||||
arrayOf(repositoryId.toString()),
|
||||
),
|
||||
).use { it.firstOrNull()?.getInt(0) ?: 0 }
|
||||
}
|
||||
|
||||
fun query(
|
||||
installed: Boolean,
|
||||
updates: Boolean,
|
||||
skipSignatureCheck: Boolean = false,
|
||||
searchQuery: String,
|
||||
section: ProductItem.Section,
|
||||
order: SortOrder,
|
||||
signal: CancellationSignal?,
|
||||
): Cursor {
|
||||
val builder = QueryBuilder()
|
||||
|
||||
val signatureMatches = if (skipSignatureCheck) "1"
|
||||
else """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND
|
||||
product.${Schema.Product.ROW_SIGNATURES} LIKE ('%.' || installed.${Schema.Installed.ROW_SIGNATURE} || '.%') AND
|
||||
product.${Schema.Product.ROW_SIGNATURES} != ''"""
|
||||
|
||||
builder += """SELECT product.rowid AS _id, product.${Schema.Product.ROW_REPOSITORY_ID},
|
||||
product.${Schema.Product.ROW_PACKAGE_NAME}, product.${Schema.Product.ROW_NAME},
|
||||
product.${Schema.Product.ROW_SUMMARY}, installed.${Schema.Installed.ROW_VERSION},
|
||||
(COALESCE(lock.${Schema.Lock.ROW_VERSION_CODE}, -1) NOT IN (0, product.${Schema.Product.ROW_VERSION_CODE}) AND
|
||||
product.${Schema.Product.ROW_COMPATIBLE} != 0 AND product.${Schema.Product.ROW_VERSION_CODE} >
|
||||
COALESCE(installed.${Schema.Installed.ROW_VERSION_CODE}, 0xffffffff) AND $signatureMatches)
|
||||
AS ${Schema.Synthetic.ROW_CAN_UPDATE}, product.${Schema.Product.ROW_COMPATIBLE},
|
||||
product.${Schema.Product.ROW_DATA_ITEM},"""
|
||||
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
builder += """(((product.${Schema.Product.ROW_NAME} LIKE ? OR
|
||||
product.${Schema.Product.ROW_SUMMARY} LIKE ?) * 7) |
|
||||
((product.${Schema.Product.ROW_PACKAGE_NAME} LIKE ?) * 3) |
|
||||
(product.${Schema.Product.ROW_DESCRIPTION} LIKE ?)) AS ${Schema.Synthetic.ROW_MATCH_RANK},"""
|
||||
builder %= List(4) { "%$searchQuery%" }
|
||||
} else {
|
||||
builder += "0 AS ${Schema.Synthetic.ROW_MATCH_RANK},"
|
||||
}
|
||||
|
||||
builder += """MAX((product.${Schema.Product.ROW_COMPATIBLE} AND
|
||||
(installed.${Schema.Installed.ROW_SIGNATURE} IS NULL OR $signatureMatches)) ||
|
||||
PRINTF('%016X', product.${Schema.Product.ROW_VERSION_CODE})) FROM ${Schema.Product.name} AS product"""
|
||||
builder += """JOIN ${Schema.Repository.name} AS repository
|
||||
ON product.${Schema.Product.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID}"""
|
||||
builder += """LEFT JOIN ${Schema.Lock.name} AS lock
|
||||
ON product.${Schema.Product.ROW_PACKAGE_NAME} = lock.${Schema.Lock.ROW_PACKAGE_NAME}"""
|
||||
|
||||
if (!installed && !updates) {
|
||||
builder += "LEFT"
|
||||
}
|
||||
builder += """JOIN ${Schema.Installed.name} AS installed
|
||||
ON product.${Schema.Product.ROW_PACKAGE_NAME} = installed.${Schema.Installed.ROW_PACKAGE_NAME}"""
|
||||
|
||||
if (section is ProductItem.Section.Category) {
|
||||
builder += """JOIN ${Schema.Category.name} AS category
|
||||
ON product.${Schema.Product.ROW_PACKAGE_NAME} = category.${Schema.Product.ROW_PACKAGE_NAME}"""
|
||||
}
|
||||
|
||||
builder += """WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND
|
||||
repository.${Schema.Repository.ROW_DELETED} == 0"""
|
||||
|
||||
if (section is ProductItem.Section.Category) {
|
||||
builder += "AND category.${Schema.Category.ROW_NAME} = ?"
|
||||
builder %= section.name
|
||||
} else if (section is ProductItem.Section.Repository) {
|
||||
builder += "AND product.${Schema.Product.ROW_REPOSITORY_ID} = ?"
|
||||
builder %= section.id.toString()
|
||||
}
|
||||
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
builder += """AND ${Schema.Synthetic.ROW_MATCH_RANK} > 0"""
|
||||
}
|
||||
|
||||
builder += "GROUP BY product.${Schema.Product.ROW_PACKAGE_NAME} HAVING 1"
|
||||
|
||||
if (updates) {
|
||||
builder += "AND ${Schema.Synthetic.ROW_CAN_UPDATE}"
|
||||
}
|
||||
builder += "ORDER BY"
|
||||
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
builder += """${Schema.Synthetic.ROW_MATCH_RANK} DESC,"""
|
||||
}
|
||||
|
||||
when (order) {
|
||||
SortOrder.UPDATED -> builder += "product.${Schema.Product.ROW_UPDATED} DESC,"
|
||||
SortOrder.ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC,"
|
||||
SortOrder.NAME -> Unit
|
||||
}::class
|
||||
builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC"
|
||||
|
||||
return builder.query(db, signal).observable(Subject.Products)
|
||||
}
|
||||
|
||||
private fun transform(cursor: Cursor): Product {
|
||||
return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA))
|
||||
.jsonParse {
|
||||
it.product().apply {
|
||||
this.repositoryId = cursor
|
||||
.getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID))
|
||||
this.description = cursor
|
||||
.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DESCRIPTION))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun transformPackageName(cursor: Cursor): String {
|
||||
return cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME))
|
||||
}
|
||||
|
||||
fun transformItem(cursor: Cursor): ProductItem {
|
||||
return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA_ITEM))
|
||||
.jsonParse {
|
||||
it.productItem().apply {
|
||||
this.repositoryId = cursor
|
||||
.getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID))
|
||||
this.packageName = cursor
|
||||
.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME))
|
||||
this.name = cursor
|
||||
.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_NAME))
|
||||
this.summary = cursor
|
||||
.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_SUMMARY))
|
||||
this.installedVersion = cursor
|
||||
.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION))
|
||||
.orEmpty()
|
||||
this.compatible = cursor
|
||||
.getInt(cursor.getColumnIndexOrThrow(Schema.Product.ROW_COMPATIBLE)) != 0
|
||||
this.canUpdate = cursor
|
||||
.getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_CAN_UPDATE)) != 0
|
||||
this.matchRank = cursor
|
||||
.getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_MATCH_RANK))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object CategoryAdapter {
|
||||
|
||||
fun getAllStream(): Flow<Set<String>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
||||
.map { getAll() }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
private suspend fun getAll(): Set<String> = withContext(Dispatchers.IO) {
|
||||
val builder = QueryBuilder()
|
||||
|
||||
builder += """SELECT DISTINCT category.${Schema.Category.ROW_NAME}
|
||||
FROM ${Schema.Category.name} AS category
|
||||
JOIN ${Schema.Repository.name} AS repository
|
||||
ON category.${Schema.Category.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID}
|
||||
WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND
|
||||
repository.${Schema.Repository.ROW_DELETED} == 0"""
|
||||
|
||||
builder.query(db, null).use { cursor ->
|
||||
cursor.asSequence().map {
|
||||
it.getString(it.getColumnIndexOrThrow(Schema.Category.ROW_NAME))
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object InstalledAdapter {
|
||||
|
||||
fun getStream(packageName: String): Flow<InstalledItem?> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
||||
.map { get(packageName, null) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun get(packageName: String, signal: CancellationSignal?): InstalledItem? {
|
||||
return db.query(
|
||||
Schema.Installed.name,
|
||||
columns = arrayOf(
|
||||
Schema.Installed.ROW_PACKAGE_NAME,
|
||||
Schema.Installed.ROW_VERSION,
|
||||
Schema.Installed.ROW_VERSION_CODE,
|
||||
Schema.Installed.ROW_SIGNATURE,
|
||||
),
|
||||
selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
|
||||
signal = signal,
|
||||
).use { it.firstOrNull()?.let(::transform) }
|
||||
}
|
||||
|
||||
private fun put(installedItem: InstalledItem, notify: Boolean) {
|
||||
db.insertOrReplace(
|
||||
true,
|
||||
Schema.Installed.name,
|
||||
ContentValues().apply {
|
||||
put(Schema.Installed.ROW_PACKAGE_NAME, installedItem.packageName)
|
||||
put(Schema.Installed.ROW_VERSION, installedItem.version)
|
||||
put(Schema.Installed.ROW_VERSION_CODE, installedItem.versionCode)
|
||||
put(Schema.Installed.ROW_SIGNATURE, installedItem.signature)
|
||||
},
|
||||
)
|
||||
if (notify) {
|
||||
notifyChanged(Subject.Products)
|
||||
}
|
||||
}
|
||||
|
||||
fun put(installedItem: InstalledItem) = put(installedItem, true)
|
||||
|
||||
fun putAll(installedItems: List<InstalledItem>) {
|
||||
db.transaction {
|
||||
db.delete(Schema.Installed.name, null, null)
|
||||
installedItems.forEach { put(it, false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(packageName: String) {
|
||||
val count = db.delete(
|
||||
Schema.Installed.name,
|
||||
"${Schema.Installed.ROW_PACKAGE_NAME} = ?",
|
||||
arrayOf(packageName),
|
||||
)
|
||||
if (count > 0) {
|
||||
notifyChanged(Subject.Products)
|
||||
}
|
||||
}
|
||||
|
||||
private fun transform(cursor: Cursor): InstalledItem {
|
||||
return InstalledItem(
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_PACKAGE_NAME)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION_CODE)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_SIGNATURE)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object LockAdapter {
|
||||
private fun put(lock: Pair<String, Long>, notify: Boolean) {
|
||||
db.insertOrReplace(
|
||||
true,
|
||||
Schema.Lock.name,
|
||||
ContentValues().apply {
|
||||
put(Schema.Lock.ROW_PACKAGE_NAME, lock.first)
|
||||
put(Schema.Lock.ROW_VERSION_CODE, lock.second)
|
||||
},
|
||||
)
|
||||
if (notify) {
|
||||
notifyChanged(Subject.Products)
|
||||
}
|
||||
}
|
||||
|
||||
fun put(lock: Pair<String, Long>) = put(lock, true)
|
||||
|
||||
fun putAll(locks: List<Pair<String, Long>>) {
|
||||
db.transaction {
|
||||
db.delete(Schema.Lock.name, null, null)
|
||||
locks.forEach { put(it, false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(packageName: String) {
|
||||
db.delete(Schema.Lock.name, "${Schema.Lock.ROW_PACKAGE_NAME} = ?", arrayOf(packageName))
|
||||
notifyChanged(Subject.Products)
|
||||
}
|
||||
}
|
||||
|
||||
object UpdaterAdapter {
|
||||
private val Table.temporaryName: String
|
||||
get() = "${name}_temporary"
|
||||
|
||||
fun createTemporaryTable() {
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
|
||||
db.execSQL(Schema.Product.formatCreateTable(Schema.Product.temporaryName))
|
||||
db.execSQL(Schema.Category.formatCreateTable(Schema.Category.temporaryName))
|
||||
}
|
||||
|
||||
fun putTemporary(products: List<Product>) {
|
||||
db.transaction {
|
||||
for (product in products) {
|
||||
// Format signatures like ".signature1.signature2." for easier select
|
||||
val signatures = product.signatures.joinToString { ".$it" }
|
||||
.let { if (it.isNotEmpty()) "$it." else "" }
|
||||
db.insertOrReplace(
|
||||
true,
|
||||
Schema.Product.temporaryName,
|
||||
ContentValues().apply {
|
||||
put(Schema.Product.ROW_REPOSITORY_ID, product.repositoryId)
|
||||
put(Schema.Product.ROW_PACKAGE_NAME, product.packageName)
|
||||
put(Schema.Product.ROW_NAME, product.name)
|
||||
put(Schema.Product.ROW_SUMMARY, product.summary)
|
||||
put(Schema.Product.ROW_DESCRIPTION, product.description)
|
||||
put(Schema.Product.ROW_ADDED, product.added)
|
||||
put(Schema.Product.ROW_UPDATED, product.updated)
|
||||
put(Schema.Product.ROW_VERSION_CODE, product.versionCode)
|
||||
put(Schema.Product.ROW_SIGNATURES, signatures)
|
||||
put(Schema.Product.ROW_COMPATIBLE, if (product.compatible) 1 else 0)
|
||||
put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize))
|
||||
put(
|
||||
Schema.Product.ROW_DATA_ITEM,
|
||||
jsonGenerate(product.item()::serialize),
|
||||
)
|
||||
},
|
||||
)
|
||||
for (category in product.categories) {
|
||||
db.insertOrReplace(
|
||||
true,
|
||||
Schema.Category.temporaryName,
|
||||
ContentValues().apply {
|
||||
put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId)
|
||||
put(Schema.Category.ROW_PACKAGE_NAME, product.packageName)
|
||||
put(Schema.Category.ROW_NAME, category)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun finishTemporary(repository: Repository, success: Boolean) {
|
||||
if (success) {
|
||||
db.transaction {
|
||||
db.delete(
|
||||
Schema.Product.name,
|
||||
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
|
||||
arrayOf(repository.id.toString()),
|
||||
)
|
||||
db.delete(
|
||||
Schema.Category.name,
|
||||
"${Schema.Category.ROW_REPOSITORY_ID} = ?",
|
||||
arrayOf(repository.id.toString()),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO ${Schema.Product.name} SELECT * " +
|
||||
"FROM ${Schema.Product.temporaryName}",
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO ${Schema.Category.name} SELECT * " +
|
||||
"FROM ${Schema.Category.temporaryName}",
|
||||
)
|
||||
RepositoryAdapter.putWithoutNotification(repository, true, db)
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
|
||||
}
|
||||
notifyChanged(
|
||||
Subject.Repositories,
|
||||
Subject.Repository(repository.id),
|
||||
Subject.Products,
|
||||
)
|
||||
} else {
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.looker.droidify.database
|
||||
|
||||
import android.database.ContentObservable
|
||||
import android.database.ContentObserver
|
||||
import android.database.Cursor
|
||||
import android.database.CursorWrapper
|
||||
|
||||
class ObservableCursor(
|
||||
cursor: Cursor,
|
||||
private val observable: (
|
||||
register: Boolean,
|
||||
observer: () -> Unit
|
||||
) -> Unit
|
||||
) : CursorWrapper(cursor) {
|
||||
private var registered = false
|
||||
private val contentObservable = ContentObservable()
|
||||
|
||||
private val onChange: () -> Unit = {
|
||||
contentObservable.dispatchChange(false, null)
|
||||
}
|
||||
|
||||
init {
|
||||
observable(true, onChange)
|
||||
registered = true
|
||||
}
|
||||
|
||||
override fun registerContentObserver(observer: ContentObserver) {
|
||||
super.registerContentObserver(observer)
|
||||
contentObservable.registerObserver(observer)
|
||||
}
|
||||
|
||||
override fun unregisterContentObserver(observer: ContentObserver) {
|
||||
super.unregisterContentObserver(observer)
|
||||
contentObservable.unregisterObserver(observer)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
@Suppress("DEPRECATION")
|
||||
override fun requery(): Boolean {
|
||||
if (!registered) {
|
||||
observable(true, onChange)
|
||||
registered = true
|
||||
}
|
||||
return super.requery()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
@Suppress("DEPRECATION")
|
||||
override fun deactivate() {
|
||||
super.deactivate()
|
||||
deactivateOrClose()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
super.close()
|
||||
contentObservable.unregisterAll()
|
||||
deactivateOrClose()
|
||||
}
|
||||
|
||||
private fun deactivateOrClose() {
|
||||
observable(false, onChange)
|
||||
registered = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.looker.droidify.database
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.CancellationSignal
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.utility.common.extension.asSequence
|
||||
import com.looker.droidify.utility.common.log
|
||||
|
||||
class QueryBuilder {
|
||||
|
||||
private val builder = StringBuilder(256)
|
||||
private val arguments = mutableListOf<String>()
|
||||
|
||||
operator fun plusAssign(query: String) {
|
||||
if (builder.isNotEmpty()) {
|
||||
builder.append(" ")
|
||||
}
|
||||
builder.trimAndJoin(query)
|
||||
}
|
||||
|
||||
operator fun remAssign(argument: String) {
|
||||
this.arguments += argument
|
||||
}
|
||||
|
||||
operator fun remAssign(arguments: List<String>) {
|
||||
this.arguments += arguments
|
||||
}
|
||||
|
||||
fun query(db: SQLiteDatabase, signal: CancellationSignal?): Cursor {
|
||||
val query = builder.toString()
|
||||
val arguments = arguments.toTypedArray()
|
||||
if (BuildConfig.DEBUG) {
|
||||
synchronized(QueryBuilder::class.java) {
|
||||
log(query)
|
||||
db.rawQuery("EXPLAIN QUERY PLAN $query", arguments).use {
|
||||
it.asSequence()
|
||||
.forEach { log(":: ${it.getString(it.getColumnIndex("detail"))}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
return db.rawQuery(query, arguments, signal)
|
||||
}
|
||||
}
|
||||
|
||||
fun StringBuilder.trimAndJoin(
|
||||
input: String,
|
||||
) {
|
||||
var isFirstLine = true
|
||||
var startOfLine = 0
|
||||
for (i in input.indices) {
|
||||
val char = input[i]
|
||||
when {
|
||||
char == '\n' -> {
|
||||
trimAndAppendLine(input, startOfLine, i, this, isFirstLine)
|
||||
isFirstLine = false
|
||||
startOfLine = i + 1
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (i == input.lastIndex) {
|
||||
trimAndAppendLine(input, startOfLine, i + 1, this, isFirstLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun trimAndAppendLine(
|
||||
input: String,
|
||||
start: Int,
|
||||
end: Int,
|
||||
builder: StringBuilder,
|
||||
isFirstLine: Boolean,
|
||||
) {
|
||||
var lineStart = start
|
||||
var lineEnd = end - 1
|
||||
|
||||
while (lineStart <= lineEnd && input[lineStart].isWhitespace()) {
|
||||
lineStart++
|
||||
}
|
||||
|
||||
while (lineEnd >= lineStart && input[lineEnd].isWhitespace()) {
|
||||
lineEnd--
|
||||
}
|
||||
|
||||
if (lineStart <= lineEnd) {
|
||||
if (!isFirstLine) {
|
||||
builder.append(' ')
|
||||
}
|
||||
builder.append(input, lineStart, lineEnd + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.looker.droidify.database
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import androidx.loader.content.AsyncTaskLoader
|
||||
|
||||
class QueryLoader(context: Context, private val query: (CancellationSignal) -> Cursor?) :
|
||||
AsyncTaskLoader<Cursor>(context) {
|
||||
private val observer = ForceLoadContentObserver()
|
||||
private var cancellationSignal: CancellationSignal? = null
|
||||
private var cursor: Cursor? = null
|
||||
|
||||
override fun loadInBackground(): Cursor? {
|
||||
val cancellationSignal = synchronized(this) {
|
||||
if (isLoadInBackgroundCanceled) {
|
||||
throw OperationCanceledException()
|
||||
}
|
||||
val cancellationSignal = CancellationSignal()
|
||||
this.cancellationSignal = cancellationSignal
|
||||
cancellationSignal
|
||||
}
|
||||
try {
|
||||
val cursor = query(cancellationSignal)
|
||||
if (cursor != null) {
|
||||
try {
|
||||
cursor.count // Ensure the cursor window is filled
|
||||
cursor.registerContentObserver(observer)
|
||||
} catch (e: Exception) {
|
||||
cursor.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return cursor
|
||||
} finally {
|
||||
synchronized(this) {
|
||||
this.cancellationSignal = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelLoadInBackground() {
|
||||
super.cancelLoadInBackground()
|
||||
|
||||
synchronized(this) {
|
||||
cancellationSignal?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun deliverResult(data: Cursor?) {
|
||||
if (isReset) {
|
||||
data?.close()
|
||||
} else {
|
||||
val oldCursor = cursor
|
||||
cursor = data
|
||||
if (isStarted) {
|
||||
super.deliverResult(data)
|
||||
}
|
||||
if (oldCursor != data) {
|
||||
oldCursor.closeIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartLoading() {
|
||||
cursor?.let(this::deliverResult)
|
||||
if (takeContentChanged() || cursor == null) {
|
||||
forceLoad()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopLoading() {
|
||||
cancelLoad()
|
||||
}
|
||||
|
||||
override fun onCanceled(data: Cursor?) {
|
||||
data.closeIfNeeded()
|
||||
}
|
||||
|
||||
override fun onReset() {
|
||||
super.onReset()
|
||||
|
||||
stopLoading()
|
||||
cursor.closeIfNeeded()
|
||||
cursor = null
|
||||
}
|
||||
|
||||
private fun Cursor?.closeIfNeeded() {
|
||||
if (this != null && !isClosed) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.looker.droidify.database
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.looker.droidify.utility.common.Exporter
|
||||
import com.looker.droidify.utility.common.extension.Json
|
||||
import com.looker.droidify.utility.common.extension.forEach
|
||||
import com.looker.droidify.utility.common.extension.forEachKey
|
||||
import com.looker.droidify.utility.common.extension.parseDictionary
|
||||
import com.looker.droidify.utility.common.extension.writeArray
|
||||
import com.looker.droidify.utility.common.extension.writeDictionary
|
||||
import com.looker.droidify.di.ApplicationScope
|
||||
import com.looker.droidify.di.IoDispatcher
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.utility.serialization.repository
|
||||
import com.looker.droidify.utility.serialization.serialize
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Singleton
|
||||
class RepositoryExporter @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
|
||||
) : Exporter<List<Repository>> {
|
||||
override suspend fun export(item: List<Repository>, target: Uri) {
|
||||
scope.launch(ioDispatcher) {
|
||||
val stream = context.contentResolver.openOutputStream(target)
|
||||
Json.factory.createGenerator(stream).use { generator ->
|
||||
generator.writeDictionary {
|
||||
writeArray("repositories") {
|
||||
item.map {
|
||||
it.copy(
|
||||
id = -1,
|
||||
mirrors = if (it.enabled) it.mirrors else emptyList(),
|
||||
lastModified = "",
|
||||
entityTag = ""
|
||||
)
|
||||
}.forEach { repo ->
|
||||
writeDictionary {
|
||||
repo.serialize(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun import(target: Uri): List<Repository> = withContext(ioDispatcher) {
|
||||
val list = mutableListOf<Repository>()
|
||||
val stream = context.contentResolver.openInputStream(target)
|
||||
Json.factory.createParser(stream).use { generator ->
|
||||
generator?.parseDictionary {
|
||||
forEachKey {
|
||||
if (it.array("repositories")) {
|
||||
forEach(JsonToken.START_OBJECT) {
|
||||
val repo = repository()
|
||||
list.add(repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.looker.droidify.datastore
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.IOException
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.looker.droidify.datastore.model.AutoSync
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.datastore.model.ProxyPreference
|
||||
import com.looker.droidify.datastore.model.ProxyType
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.droidify.datastore.model.Theme
|
||||
import com.looker.droidify.utility.common.Exporter
|
||||
import com.looker.droidify.utility.common.extension.updateAsMutable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
class PreferenceSettingsRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val exporter: Exporter<Settings>,
|
||||
) : SettingsRepository {
|
||||
override val data: Flow<Settings> = dataStore.data
|
||||
.catch { exception ->
|
||||
if (exception is IOException) {
|
||||
Log.e("TAG", "Error reading preferences.", exception)
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}.map(::mapSettings)
|
||||
|
||||
override suspend fun getInitial(): Settings {
|
||||
return data.first()
|
||||
}
|
||||
|
||||
override suspend fun export(target: Uri) {
|
||||
val currentSettings = getInitial()
|
||||
exporter.export(currentSettings, target)
|
||||
}
|
||||
|
||||
override suspend fun import(target: Uri) {
|
||||
val importedSettings = exporter.import(target)
|
||||
val updatedFavorites = importedSettings.favouriteApps +
|
||||
getInitial().favouriteApps
|
||||
val updatedSettings = importedSettings.copy(favouriteApps = updatedFavorites)
|
||||
dataStore.edit {
|
||||
it.setting(updatedSettings)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setLanguage(language: String) =
|
||||
LANGUAGE.update(language)
|
||||
|
||||
override suspend fun enableIncompatibleVersion(enable: Boolean) =
|
||||
INCOMPATIBLE_VERSIONS.update(enable)
|
||||
|
||||
override suspend fun enableNotifyUpdates(enable: Boolean) =
|
||||
NOTIFY_UPDATES.update(enable)
|
||||
|
||||
override suspend fun enableUnstableUpdates(enable: Boolean) =
|
||||
UNSTABLE_UPDATES.update(enable)
|
||||
|
||||
override suspend fun setIgnoreSignature(enable: Boolean) =
|
||||
IGNORE_SIGNATURE.update(enable)
|
||||
|
||||
override suspend fun setTheme(theme: Theme) =
|
||||
THEME.update(theme.name)
|
||||
|
||||
override suspend fun setDynamicTheme(enable: Boolean) =
|
||||
DYNAMIC_THEME.update(enable)
|
||||
|
||||
override suspend fun setInstallerType(installerType: InstallerType) =
|
||||
INSTALLER_TYPE.update(installerType.name)
|
||||
|
||||
override suspend fun setAutoUpdate(allow: Boolean) =
|
||||
AUTO_UPDATE.update(allow)
|
||||
|
||||
override suspend fun setAutoSync(autoSync: AutoSync) =
|
||||
AUTO_SYNC.update(autoSync.name)
|
||||
|
||||
override suspend fun setSortOrder(sortOrder: SortOrder) =
|
||||
SORT_ORDER.update(sortOrder.name)
|
||||
|
||||
override suspend fun setProxyType(proxyType: ProxyType) =
|
||||
PROXY_TYPE.update(proxyType.name)
|
||||
|
||||
override suspend fun setProxyHost(proxyHost: String) =
|
||||
PROXY_HOST.update(proxyHost)
|
||||
|
||||
override suspend fun setProxyPort(proxyPort: Int) =
|
||||
PROXY_PORT.update(proxyPort)
|
||||
|
||||
override suspend fun setCleanUpInterval(interval: Duration) =
|
||||
CLEAN_UP_INTERVAL.update(interval.inWholeHours)
|
||||
|
||||
override suspend fun setCleanupInstant() =
|
||||
LAST_CLEAN_UP.update(Clock.System.now().toEpochMilliseconds())
|
||||
|
||||
override suspend fun setHomeScreenSwiping(value: Boolean) =
|
||||
HOME_SCREEN_SWIPING.update(value)
|
||||
|
||||
override suspend fun toggleFavourites(packageName: String) {
|
||||
dataStore.edit { preference ->
|
||||
val currentSet = preference[FAVOURITE_APPS] ?: emptySet()
|
||||
val newSet = currentSet.updateAsMutable {
|
||||
if (!add(packageName)) remove(packageName)
|
||||
}
|
||||
preference[FAVOURITE_APPS] = newSet
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapSettings(preferences: Preferences): Settings {
|
||||
val installerType =
|
||||
InstallerType.valueOf(preferences[INSTALLER_TYPE] ?: InstallerType.Default.name)
|
||||
|
||||
val language = preferences[LANGUAGE] ?: "system"
|
||||
val incompatibleVersions = preferences[INCOMPATIBLE_VERSIONS] ?: false
|
||||
val notifyUpdate = preferences[NOTIFY_UPDATES] ?: true
|
||||
val unstableUpdate = preferences[UNSTABLE_UPDATES] ?: false
|
||||
val ignoreSignature = preferences[IGNORE_SIGNATURE] ?: false
|
||||
val theme = Theme.valueOf(preferences[THEME] ?: Theme.SYSTEM.name)
|
||||
val dynamicTheme = preferences[DYNAMIC_THEME] ?: false
|
||||
val autoUpdate = preferences[AUTO_UPDATE] ?: false
|
||||
val autoSync = AutoSync.valueOf(preferences[AUTO_SYNC] ?: AutoSync.WIFI_ONLY.name)
|
||||
val sortOrder = SortOrder.valueOf(preferences[SORT_ORDER] ?: SortOrder.UPDATED.name)
|
||||
val type = ProxyType.valueOf(preferences[PROXY_TYPE] ?: ProxyType.DIRECT.name)
|
||||
val host = preferences[PROXY_HOST] ?: "localhost"
|
||||
val port = preferences[PROXY_PORT] ?: 9050
|
||||
val proxy = ProxyPreference(type = type, host = host, port = port)
|
||||
val cleanUpInterval = preferences[CLEAN_UP_INTERVAL]?.hours ?: 12L.hours
|
||||
val lastCleanup = preferences[LAST_CLEAN_UP]?.let { Instant.fromEpochMilliseconds(it) }
|
||||
val favouriteApps = preferences[FAVOURITE_APPS] ?: emptySet()
|
||||
val homeScreenSwiping = preferences[HOME_SCREEN_SWIPING] ?: true
|
||||
|
||||
return Settings(
|
||||
language = language,
|
||||
incompatibleVersions = incompatibleVersions,
|
||||
notifyUpdate = notifyUpdate,
|
||||
unstableUpdate = unstableUpdate,
|
||||
ignoreSignature = ignoreSignature,
|
||||
theme = theme,
|
||||
dynamicTheme = dynamicTheme,
|
||||
installerType = installerType,
|
||||
autoUpdate = autoUpdate,
|
||||
autoSync = autoSync,
|
||||
sortOrder = sortOrder,
|
||||
proxy = proxy,
|
||||
cleanUpInterval = cleanUpInterval,
|
||||
lastCleanup = lastCleanup,
|
||||
favouriteApps = favouriteApps,
|
||||
homeScreenSwiping = homeScreenSwiping,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend inline fun <T> Preferences.Key<T>.update(newValue: T) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[this] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
companion object PreferencesKeys {
|
||||
val LANGUAGE = stringPreferencesKey("key_language")
|
||||
val INCOMPATIBLE_VERSIONS = booleanPreferencesKey("key_incompatible_versions")
|
||||
val NOTIFY_UPDATES = booleanPreferencesKey("key_notify_updates")
|
||||
val UNSTABLE_UPDATES = booleanPreferencesKey("key_unstable_updates")
|
||||
val IGNORE_SIGNATURE = booleanPreferencesKey("key_ignore_signature")
|
||||
val DYNAMIC_THEME = booleanPreferencesKey("key_dynamic_theme")
|
||||
val AUTO_UPDATE = booleanPreferencesKey("key_auto_updates")
|
||||
val PROXY_HOST = stringPreferencesKey("key_proxy_host")
|
||||
val PROXY_PORT = intPreferencesKey("key_proxy_port")
|
||||
val CLEAN_UP_INTERVAL = longPreferencesKey("key_clean_up_interval")
|
||||
val LAST_CLEAN_UP = longPreferencesKey("key_last_clean_up_time")
|
||||
val FAVOURITE_APPS = stringSetPreferencesKey("key_favourite_apps")
|
||||
val HOME_SCREEN_SWIPING = booleanPreferencesKey("key_home_swiping")
|
||||
|
||||
// Enums
|
||||
val THEME = stringPreferencesKey("key_theme")
|
||||
val INSTALLER_TYPE = stringPreferencesKey("key_installer_type")
|
||||
val AUTO_SYNC = stringPreferencesKey("key_auto_sync")
|
||||
val SORT_ORDER = stringPreferencesKey("key_sort_order")
|
||||
val PROXY_TYPE = stringPreferencesKey("key_proxy_type")
|
||||
|
||||
fun MutablePreferences.setting(settings: Settings): Preferences {
|
||||
set(LANGUAGE, settings.language)
|
||||
set(INCOMPATIBLE_VERSIONS, settings.incompatibleVersions)
|
||||
set(NOTIFY_UPDATES, settings.notifyUpdate)
|
||||
set(UNSTABLE_UPDATES, settings.unstableUpdate)
|
||||
set(THEME, settings.theme.name)
|
||||
set(DYNAMIC_THEME, settings.dynamicTheme)
|
||||
set(INSTALLER_TYPE, settings.installerType.name)
|
||||
set(AUTO_UPDATE, settings.autoUpdate)
|
||||
set(AUTO_SYNC, settings.autoSync.name)
|
||||
set(SORT_ORDER, settings.sortOrder.name)
|
||||
set(PROXY_TYPE, settings.proxy.type.name)
|
||||
set(PROXY_HOST, settings.proxy.host)
|
||||
set(PROXY_PORT, settings.proxy.port)
|
||||
set(CLEAN_UP_INTERVAL, settings.cleanUpInterval.inWholeHours)
|
||||
set(LAST_CLEAN_UP, settings.lastCleanup?.toEpochMilliseconds() ?: 0L)
|
||||
set(FAVOURITE_APPS, settings.favouriteApps)
|
||||
set(HOME_SCREEN_SWIPING, settings.homeScreenSwiping)
|
||||
return this.toPreferences()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.looker.droidify.datastore
|
||||
|
||||
import androidx.datastore.core.Serializer
|
||||
import com.looker.droidify.datastore.model.AutoSync
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.datastore.model.ProxyPreference
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.droidify.datastore.model.Theme
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
|
||||
@Serializable
|
||||
data class Settings(
|
||||
val language: String = "system",
|
||||
val incompatibleVersions: Boolean = false,
|
||||
val notifyUpdate: Boolean = true,
|
||||
val unstableUpdate: Boolean = false,
|
||||
val ignoreSignature: Boolean = false,
|
||||
val theme: Theme = Theme.SYSTEM,
|
||||
val dynamicTheme: Boolean = false,
|
||||
val installerType: InstallerType = InstallerType.Default,
|
||||
val autoUpdate: Boolean = false,
|
||||
val autoSync: AutoSync = AutoSync.WIFI_ONLY,
|
||||
val sortOrder: SortOrder = SortOrder.UPDATED,
|
||||
val proxy: ProxyPreference = ProxyPreference(),
|
||||
val cleanUpInterval: Duration = 12.hours,
|
||||
val lastCleanup: Instant? = null,
|
||||
val favouriteApps: Set<String> = emptySet(),
|
||||
val homeScreenSwiping: Boolean = true,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
object SettingsSerializer : Serializer<Settings> {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
override val defaultValue: Settings = Settings()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): Settings {
|
||||
return try {
|
||||
json.decodeFromStream(input)
|
||||
} catch (e: SerializationException) {
|
||||
e.printStackTrace()
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: Settings, output: OutputStream) {
|
||||
try {
|
||||
json.encodeToStream(t, output)
|
||||
} catch (e: SerializationException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.looker.droidify.datastore
|
||||
|
||||
import android.net.Uri
|
||||
import com.looker.droidify.datastore.model.AutoSync
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.datastore.model.ProxyType
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.droidify.datastore.model.Theme
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlin.time.Duration
|
||||
|
||||
interface SettingsRepository {
|
||||
|
||||
val data: Flow<Settings>
|
||||
|
||||
suspend fun getInitial(): Settings
|
||||
|
||||
suspend fun export(target: Uri)
|
||||
|
||||
suspend fun import(target: Uri)
|
||||
|
||||
suspend fun setLanguage(language: String)
|
||||
|
||||
suspend fun enableIncompatibleVersion(enable: Boolean)
|
||||
|
||||
suspend fun enableNotifyUpdates(enable: Boolean)
|
||||
|
||||
suspend fun enableUnstableUpdates(enable: Boolean)
|
||||
|
||||
suspend fun setIgnoreSignature(enable: Boolean)
|
||||
|
||||
suspend fun setTheme(theme: Theme)
|
||||
|
||||
suspend fun setDynamicTheme(enable: Boolean)
|
||||
|
||||
suspend fun setInstallerType(installerType: InstallerType)
|
||||
|
||||
suspend fun setAutoUpdate(allow: Boolean)
|
||||
|
||||
suspend fun setAutoSync(autoSync: AutoSync)
|
||||
|
||||
suspend fun setSortOrder(sortOrder: SortOrder)
|
||||
|
||||
suspend fun setProxyType(proxyType: ProxyType)
|
||||
|
||||
suspend fun setProxyHost(proxyHost: String)
|
||||
|
||||
suspend fun setProxyPort(proxyPort: Int)
|
||||
|
||||
suspend fun setCleanUpInterval(interval: Duration)
|
||||
|
||||
suspend fun setCleanupInstant()
|
||||
|
||||
suspend fun setHomeScreenSwiping(value: Boolean)
|
||||
|
||||
suspend fun toggleFavourites(packageName: String)
|
||||
}
|
||||
|
||||
inline fun <T> SettingsRepository.get(crossinline block: suspend Settings.() -> T): Flow<T> {
|
||||
return data.map(block).distinctUntilChanged()
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.looker.droidify.datastore.exporter
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.looker.droidify.utility.common.Exporter
|
||||
import com.looker.droidify.datastore.Settings
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
import java.io.IOException
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
class SettingsExporter(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val json: Json
|
||||
) : Exporter<Settings> {
|
||||
|
||||
override suspend fun export(item: Settings, target: Uri) {
|
||||
scope.launch(ioDispatcher) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(target).use {
|
||||
if (it != null) json.encodeToStream(item, it)
|
||||
}
|
||||
} catch (e: SerializationException) {
|
||||
e.printStackTrace()
|
||||
cancel()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun import(target: Uri): Settings = withContext(ioDispatcher) {
|
||||
try {
|
||||
context.contentResolver.openInputStream(target).use {
|
||||
checkNotNull(it) { "Null input stream for import file" }
|
||||
json.decodeFromStream(it)
|
||||
}
|
||||
} catch (e: SerializationException) {
|
||||
e.printStackTrace()
|
||||
throw IllegalStateException(e.message)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
throw IllegalStateException(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.looker.droidify.datastore.extension
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.R.string as stringRes
|
||||
import com.looker.droidify.R.style as styleRes
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.datastore.model.AutoSync
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.datastore.model.ProxyType
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.droidify.datastore.model.Theme
|
||||
import kotlin.time.Duration
|
||||
|
||||
fun Configuration.getThemeRes(theme: Theme, dynamicTheme: Boolean) = when (theme) {
|
||||
Theme.SYSTEM -> {
|
||||
if ((uiMode and Configuration.UI_MODE_NIGHT_YES) != 0) {
|
||||
if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicDark
|
||||
} else {
|
||||
styleRes.Theme_Main_Dark
|
||||
}
|
||||
} else {
|
||||
if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicLight
|
||||
} else {
|
||||
styleRes.Theme_Main_Light
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Theme.SYSTEM_BLACK -> {
|
||||
if ((uiMode and Configuration.UI_MODE_NIGHT_YES) != 0) {
|
||||
if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicAmoled
|
||||
} else {
|
||||
styleRes.Theme_Main_Amoled
|
||||
}
|
||||
} else {
|
||||
if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicLight
|
||||
} else {
|
||||
styleRes.Theme_Main_Light
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Theme.LIGHT -> if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicLight
|
||||
} else {
|
||||
styleRes.Theme_Main_Light
|
||||
}
|
||||
Theme.DARK -> if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicDark
|
||||
} else {
|
||||
styleRes.Theme_Main_Dark
|
||||
}
|
||||
Theme.AMOLED -> if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicAmoled
|
||||
} else {
|
||||
styleRes.Theme_Main_Amoled
|
||||
}
|
||||
}
|
||||
|
||||
fun Context?.toTime(duration: Duration): String {
|
||||
val time = duration.inWholeHours.toInt()
|
||||
val days = duration.inWholeDays.toInt()
|
||||
if (duration == Duration.INFINITE) return this?.getString(stringRes.never) ?: ""
|
||||
return if (time >= 24) {
|
||||
"$days " + this?.resources?.getQuantityString(
|
||||
R.plurals.days,
|
||||
days
|
||||
)
|
||||
} else {
|
||||
"$time " + this?.resources?.getQuantityString(R.plurals.hours, time)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context?.themeName(theme: Theme) = this?.let {
|
||||
when (theme) {
|
||||
Theme.SYSTEM -> getString(stringRes.system)
|
||||
Theme.SYSTEM_BLACK -> getString(stringRes.system) + " " + getString(stringRes.amoled)
|
||||
Theme.LIGHT -> getString(stringRes.light)
|
||||
Theme.DARK -> getString(stringRes.dark)
|
||||
Theme.AMOLED -> getString(stringRes.amoled)
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
fun Context?.sortOrderName(sortOrder: SortOrder) = this?.let {
|
||||
when (sortOrder) {
|
||||
SortOrder.UPDATED -> getString(stringRes.recently_updated)
|
||||
SortOrder.ADDED -> getString(stringRes.whats_new)
|
||||
SortOrder.NAME -> getString(stringRes.name)
|
||||
// SortOrder.SIZE -> getString(stringRes.size)
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
fun Context?.autoSyncName(autoSync: AutoSync) = this?.let {
|
||||
when (autoSync) {
|
||||
AutoSync.NEVER -> getString(stringRes.never)
|
||||
AutoSync.WIFI_ONLY -> getString(stringRes.only_on_wifi)
|
||||
AutoSync.WIFI_PLUGGED_IN -> getString(stringRes.only_on_wifi_with_charging)
|
||||
AutoSync.ALWAYS -> getString(stringRes.always)
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
fun Context?.proxyName(proxyType: ProxyType) = this?.let {
|
||||
when (proxyType) {
|
||||
ProxyType.DIRECT -> getString(stringRes.no_proxy)
|
||||
ProxyType.HTTP -> getString(stringRes.http_proxy)
|
||||
ProxyType.SOCKS -> getString(stringRes.socks_proxy)
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
fun Context?.installerName(installerType: InstallerType) = this?.let {
|
||||
when (installerType) {
|
||||
InstallerType.LEGACY -> getString(stringRes.legacy_installer)
|
||||
InstallerType.SESSION -> getString(stringRes.session_installer)
|
||||
InstallerType.SHIZUKU -> getString(stringRes.shizuku_installer)
|
||||
InstallerType.ROOT -> getString(stringRes.root_installer)
|
||||
}
|
||||
} ?: ""
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.looker.droidify.datastore.migration
|
||||
|
||||
import com.looker.droidify.datastore.PreferenceSettingsRepository.PreferencesKeys.setting
|
||||
import com.looker.droidify.datastore.Settings
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class ProtoToPreferenceMigration(
|
||||
private val oldDataStore: androidx.datastore.core.DataStore<Settings>
|
||||
) : androidx.datastore.core.DataMigration<androidx.datastore.preferences.core.Preferences> {
|
||||
override suspend fun cleanUp() {
|
||||
}
|
||||
|
||||
override suspend fun shouldMigrate(currentData: androidx.datastore.preferences.core.Preferences): Boolean {
|
||||
return currentData.asMap().isEmpty()
|
||||
}
|
||||
|
||||
override suspend fun migrate(currentData: androidx.datastore.preferences.core.Preferences): androidx.datastore.preferences.core.Preferences {
|
||||
val settings = oldDataStore.data.first()
|
||||
val preferences = currentData.toMutablePreferences()
|
||||
preferences.setting(settings)
|
||||
return preferences
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
enum class AutoSync {
|
||||
ALWAYS,
|
||||
WIFI_ONLY,
|
||||
WIFI_PLUGGED_IN,
|
||||
NEVER
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
import com.looker.droidify.utility.common.device.Miui
|
||||
|
||||
enum class InstallerType {
|
||||
LEGACY,
|
||||
SESSION,
|
||||
SHIZUKU,
|
||||
ROOT;
|
||||
|
||||
companion object {
|
||||
val Default: InstallerType
|
||||
get() = if (Miui.isMiui) {
|
||||
if (Miui.isMiuiOptimizationDisabled()) SESSION else LEGACY
|
||||
} else {
|
||||
SESSION
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ProxyPreference(
|
||||
val type: ProxyType = ProxyType.DIRECT,
|
||||
val host: String = "localhost",
|
||||
val port: Int = 9050
|
||||
) {
|
||||
fun update(
|
||||
newType: ProxyType? = null,
|
||||
newHost: String? = null,
|
||||
newPort: Int? = null
|
||||
): ProxyPreference = copy(
|
||||
type = newType ?: type,
|
||||
host = newHost ?: host,
|
||||
port = newPort ?: port
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
enum class ProxyType {
|
||||
DIRECT,
|
||||
HTTP,
|
||||
SOCKS
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
// todo: Add Support for sorting by size
|
||||
enum class SortOrder {
|
||||
UPDATED,
|
||||
ADDED,
|
||||
NAME
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
enum class Theme {
|
||||
SYSTEM,
|
||||
SYSTEM_BLACK,
|
||||
LIGHT,
|
||||
DARK,
|
||||
AMOLED
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.looker.droidify.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Qualifier
|
||||
annotation class IoDispatcher
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Qualifier
|
||||
annotation class DefaultDispatcher
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Qualifier
|
||||
annotation class ApplicationScope
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object CoroutinesModule {
|
||||
|
||||
@Provides
|
||||
@IoDispatcher
|
||||
fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
@Provides
|
||||
@DefaultDispatcher
|
||||
fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@ApplicationScope
|
||||
fun providesCoroutineScope(
|
||||
@DefaultDispatcher dispatcher: CoroutineDispatcher
|
||||
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.looker.droidify.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.dataStoreFile
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import com.looker.droidify.utility.common.Exporter
|
||||
import com.looker.droidify.datastore.PreferenceSettingsRepository
|
||||
import com.looker.droidify.datastore.Settings
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.SettingsSerializer
|
||||
import com.looker.droidify.datastore.exporter.SettingsExporter
|
||||
import com.looker.droidify.datastore.migration.ProtoToPreferenceMigration
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val PREFERENCES = "settings_file"
|
||||
|
||||
private const val SETTINGS = "settings"
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatastoreModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideProtoDatastore(
|
||||
@ApplicationContext context: Context,
|
||||
): DataStore<Settings> = DataStoreFactory.create(
|
||||
serializer = SettingsSerializer,
|
||||
) {
|
||||
context.dataStoreFile(PREFERENCES)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun providePreferenceDatastore(
|
||||
@ApplicationContext context: Context,
|
||||
oldDatastore: DataStore<Settings>,
|
||||
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
migrations = listOf(
|
||||
ProtoToPreferenceMigration(oldDatastore)
|
||||
)
|
||||
) {
|
||||
context.preferencesDataStoreFile(SETTINGS)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideSettingsExporter(
|
||||
@ApplicationContext context: Context,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
@IoDispatcher dispatcher: CoroutineDispatcher
|
||||
): Exporter<Settings> = SettingsExporter(
|
||||
context = context,
|
||||
scope = scope,
|
||||
ioDispatcher = dispatcher,
|
||||
json = Json {
|
||||
encodeDefaults = true
|
||||
prettyPrint = true
|
||||
}
|
||||
)
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideSettingsRepository(
|
||||
dataStore: DataStore<Preferences>,
|
||||
exporter: Exporter<Settings>
|
||||
): SettingsRepository = PreferenceSettingsRepository(dataStore, exporter)
|
||||
}
|
||||
23
app/src/main/kotlin/com/looker/droidify/di/InstallModule.kt
Normal file
23
app/src/main/kotlin/com/looker/droidify/di/InstallModule.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.looker.droidify.di
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.installer.InstallManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object InstallModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun providesInstaller(
|
||||
@ApplicationContext context: Context,
|
||||
settingsRepository: SettingsRepository
|
||||
): InstallManager = InstallManager(context, settingsRepository)
|
||||
}
|
||||
27
app/src/main/kotlin/com/looker/droidify/di/NetworkModule.kt
Normal file
27
app/src/main/kotlin/com/looker/droidify/di/NetworkModule.kt
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.looker.droidify.di
|
||||
|
||||
import com.looker.droidify.network.Downloader
|
||||
import com.looker.droidify.network.KtorDownloader
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideDownloader(
|
||||
@IoDispatcher
|
||||
dispatcher: CoroutineDispatcher
|
||||
): Downloader = KtorDownloader(
|
||||
httpClientEngine = OkHttp.create(),
|
||||
dispatcher = dispatcher,
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.looker.droidify.domain
|
||||
|
||||
import com.looker.droidify.domain.model.App
|
||||
import com.looker.droidify.domain.model.AppMinimal
|
||||
import com.looker.droidify.domain.model.Author
|
||||
import com.looker.droidify.domain.model.Package
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AppRepository {
|
||||
|
||||
fun getApps(): Flow<List<AppMinimal>>
|
||||
|
||||
fun getApp(packageName: PackageName): Flow<List<App>>
|
||||
|
||||
fun getAppFromAuthor(author: Author): Flow<List<App>>
|
||||
|
||||
fun getPackages(packageName: PackageName): Flow<List<Package>>
|
||||
|
||||
/**
|
||||
* returns true is the app is added successfully
|
||||
* returns false if the app was already in the favourites and so it is removed
|
||||
*/
|
||||
suspend fun addToFavourite(packageName: PackageName): Boolean
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.looker.droidify.domain
|
||||
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface RepoRepository {
|
||||
|
||||
suspend fun getRepo(id: Long): Repo?
|
||||
|
||||
fun getRepos(): Flow<List<Repo>>
|
||||
|
||||
suspend fun updateRepo(repo: Repo)
|
||||
|
||||
suspend fun enableRepository(repo: Repo, enable: Boolean)
|
||||
|
||||
suspend fun sync(repo: Repo): Boolean
|
||||
|
||||
suspend fun syncAll(): Boolean
|
||||
}
|
||||
83
app/src/main/kotlin/com/looker/droidify/domain/model/App.kt
Normal file
83
app/src/main/kotlin/com/looker/droidify/domain/model/App.kt
Normal file
@@ -0,0 +1,83 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
data class App(
|
||||
val repoId: Long,
|
||||
val appId: Long,
|
||||
val categories: List<String>,
|
||||
val links: Links,
|
||||
val metadata: Metadata,
|
||||
val author: Author,
|
||||
val screenshots: Screenshots,
|
||||
val graphics: Graphics,
|
||||
val donation: Donation,
|
||||
val preferredSigner: String = "",
|
||||
val packages: List<Package>
|
||||
)
|
||||
|
||||
data class Author(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val email: String,
|
||||
val web: String
|
||||
)
|
||||
|
||||
data class Donation(
|
||||
val regularUrl: String? = null,
|
||||
val bitcoinAddress: String? = null,
|
||||
val flattrId: String? = null,
|
||||
val liteCoinAddress: String? = null,
|
||||
val openCollectiveId: String? = null,
|
||||
val librePayId: String? = null,
|
||||
)
|
||||
|
||||
data class Graphics(
|
||||
val featureGraphic: String = "",
|
||||
val promoGraphic: String = "",
|
||||
val tvBanner: String = "",
|
||||
val video: String = ""
|
||||
)
|
||||
|
||||
data class Links(
|
||||
val changelog: String = "",
|
||||
val issueTracker: String = "",
|
||||
val sourceCode: String = "",
|
||||
val translation: String = "",
|
||||
val webSite: String = ""
|
||||
)
|
||||
|
||||
data class Metadata(
|
||||
val name: String,
|
||||
val packageName: PackageName,
|
||||
val added: Long,
|
||||
val description: String,
|
||||
val icon: String,
|
||||
val lastUpdated: Long,
|
||||
val license: String,
|
||||
val suggestedVersionCode: Long,
|
||||
val suggestedVersionName: String,
|
||||
val summary: String
|
||||
)
|
||||
|
||||
data class Screenshots(
|
||||
val phone: List<String> = emptyList(),
|
||||
val sevenInch: List<String> = emptyList(),
|
||||
val tenInch: List<String> = emptyList(),
|
||||
val tv: List<String> = emptyList(),
|
||||
val wear: List<String> = emptyList()
|
||||
)
|
||||
|
||||
data class AppMinimal(
|
||||
val appId: Long,
|
||||
val name: String,
|
||||
val summary: String,
|
||||
val icon: String,
|
||||
val suggestedVersion: String,
|
||||
)
|
||||
|
||||
fun App.minimal() = AppMinimal(
|
||||
appId = appId,
|
||||
name = metadata.name,
|
||||
summary = metadata.summary,
|
||||
icon = metadata.icon,
|
||||
suggestedVersion = metadata.suggestedVersionName,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
interface DataFile {
|
||||
val name: String
|
||||
val hash: String
|
||||
val size: Long
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.security.cert.Certificate
|
||||
import java.util.Locale
|
||||
|
||||
@JvmInline
|
||||
value class Fingerprint(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank() && value.length == FINGERPRINT_LENGTH) { "Invalid Fingerprint: $value" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun Fingerprint.check(found: Fingerprint): Boolean {
|
||||
return found.value.equals(value, ignoreCase = true)
|
||||
}
|
||||
|
||||
private const val FINGERPRINT_LENGTH = 64
|
||||
|
||||
fun ByteArray.hex(): String = joinToString(separator = "") { byte ->
|
||||
"%02x".format(Locale.US, byte.toInt() and 0xff)
|
||||
}
|
||||
|
||||
fun Fingerprint.formattedString(): String = value.windowed(2, 2, false)
|
||||
.take(FINGERPRINT_LENGTH / 2).joinToString(separator = " ") { it.uppercase(Locale.US) }
|
||||
|
||||
fun Certificate.fingerprint(): Fingerprint {
|
||||
val bytes = encoded
|
||||
return if (bytes.size >= 256) {
|
||||
try {
|
||||
val fingerprint = MessageDigest.getInstance("sha256").digest(bytes)
|
||||
Fingerprint(fingerprint.hex().uppercase())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Fingerprint("")
|
||||
}
|
||||
} else {
|
||||
Fingerprint("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
data class Package(
|
||||
val id: Long,
|
||||
val installed: Boolean,
|
||||
val added: Long,
|
||||
val apk: ApkFile,
|
||||
val platforms: Platforms,
|
||||
val features: List<String>,
|
||||
val antiFeatures: List<String>,
|
||||
val manifest: Manifest,
|
||||
val whatsNew: String
|
||||
)
|
||||
|
||||
data class ApkFile(
|
||||
override val name: String,
|
||||
override val hash: String,
|
||||
override val size: Long
|
||||
) : DataFile
|
||||
|
||||
data class Manifest(
|
||||
val versionCode: Long,
|
||||
val versionName: String,
|
||||
val usesSDKs: SDKs,
|
||||
val signer: Set<String>,
|
||||
val permissions: List<Permission>
|
||||
)
|
||||
|
||||
@JvmInline
|
||||
value class Platforms(val value: List<String>)
|
||||
|
||||
data class SDKs(
|
||||
val min: Int = -1,
|
||||
val max: Int = -1,
|
||||
val target: Int = -1
|
||||
)
|
||||
|
||||
// means the max sdk here and any sdk value as -1 means not valid
|
||||
data class Permission(
|
||||
val name: String,
|
||||
val sdKs: SDKs
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
@JvmInline
|
||||
value class PackageName(val name: String)
|
||||
|
||||
fun String.toPackageName() = PackageName(this)
|
||||
49
app/src/main/kotlin/com/looker/droidify/domain/model/Repo.kt
Normal file
49
app/src/main/kotlin/com/looker/droidify/domain/model/Repo.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
data class Repo(
|
||||
val id: Long,
|
||||
val enabled: Boolean,
|
||||
val address: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val fingerprint: Fingerprint?,
|
||||
val authentication: Authentication,
|
||||
val versionInfo: VersionInfo,
|
||||
val mirrors: List<String>,
|
||||
val antiFeatures: List<AntiFeature>,
|
||||
val categories: List<Category>
|
||||
) {
|
||||
val shouldAuthenticate =
|
||||
authentication.username.isNotEmpty() && authentication.password.isNotEmpty()
|
||||
|
||||
fun update(fingerprint: Fingerprint, timestamp: Long? = null, etag: String? = null): Repo {
|
||||
return copy(
|
||||
fingerprint = fingerprint,
|
||||
versionInfo = timestamp?.let { VersionInfo(timestamp = it, etag = etag) } ?: versionInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AntiFeature(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val icon: String = "",
|
||||
val description: String = ""
|
||||
)
|
||||
|
||||
data class Category(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val icon: String = "",
|
||||
val description: String = ""
|
||||
)
|
||||
|
||||
data class Authentication(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class VersionInfo(
|
||||
val timestamp: Long,
|
||||
val etag: String?
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.looker.droidify.graphics
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
open class DrawableWrapper(val drawable: Drawable) : Drawable() {
|
||||
init {
|
||||
drawable.callback = object : Callback {
|
||||
override fun invalidateDrawable(who: Drawable) {
|
||||
callback?.invalidateDrawable(who)
|
||||
}
|
||||
|
||||
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
|
||||
callback?.scheduleDrawable(who, what, `when`)
|
||||
}
|
||||
|
||||
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
|
||||
callback?.unscheduleDrawable(who, what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBoundsChange(bounds: Rect) {
|
||||
drawable.bounds = bounds
|
||||
}
|
||||
|
||||
override fun getIntrinsicWidth(): Int = drawable.intrinsicWidth
|
||||
override fun getIntrinsicHeight(): Int = drawable.intrinsicHeight
|
||||
override fun getMinimumWidth(): Int = drawable.minimumWidth
|
||||
override fun getMinimumHeight(): Int = drawable.minimumHeight
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
drawable.draw(canvas)
|
||||
}
|
||||
|
||||
override fun getAlpha(): Int {
|
||||
return drawable.alpha
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
drawable.alpha = alpha
|
||||
}
|
||||
|
||||
override fun getColorFilter(): ColorFilter? {
|
||||
return drawable.colorFilter
|
||||
}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||
drawable.colorFilter = colorFilter
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun getOpacity(): Int = drawable.opacity
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.looker.droidify.graphics
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class PaddingDrawable(
|
||||
drawable: Drawable,
|
||||
private val horizontalFactor: Float,
|
||||
private val aspectRatio: Float = 16f / 9f
|
||||
) : DrawableWrapper(drawable) {
|
||||
override fun getIntrinsicWidth(): Int =
|
||||
(horizontalFactor * super.getIntrinsicWidth()).roundToInt()
|
||||
|
||||
override fun getIntrinsicHeight(): Int =
|
||||
((horizontalFactor * aspectRatio) * super.getIntrinsicHeight()).roundToInt()
|
||||
|
||||
override fun onBoundsChange(bounds: Rect) {
|
||||
val width = (bounds.width() / horizontalFactor).roundToInt()
|
||||
val height = (bounds.height() / (horizontalFactor * aspectRatio)).roundToInt()
|
||||
val left = (bounds.width() - width) / 2
|
||||
val top = (bounds.height() - height) / 2
|
||||
drawable.setBounds(
|
||||
bounds.left + left,
|
||||
bounds.top + top,
|
||||
bounds.left + left + width,
|
||||
bounds.top + top + height
|
||||
)
|
||||
}
|
||||
}
|
||||
118
app/src/main/kotlin/com/looker/droidify/index/IndexMerger.kt
Normal file
118
app/src/main/kotlin/com/looker/droidify/index/IndexMerger.kt
Normal file
@@ -0,0 +1,118 @@
|
||||
package com.looker.droidify.index
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.looker.droidify.utility.common.extension.Json
|
||||
import com.looker.droidify.utility.common.extension.asSequence
|
||||
import com.looker.droidify.utility.common.extension.collectNotNull
|
||||
import com.looker.droidify.utility.common.extension.writeDictionary
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.utility.serialization.product
|
||||
import com.looker.droidify.utility.serialization.release
|
||||
import com.looker.droidify.utility.serialization.serialize
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
|
||||
class IndexMerger(file: File) : Closeable {
|
||||
private val db = SQLiteDatabase.openOrCreateDatabase(file, null)
|
||||
|
||||
init {
|
||||
db.execWithResult("PRAGMA synchronous = OFF")
|
||||
db.execWithResult("PRAGMA journal_mode = OFF")
|
||||
db.execSQL(
|
||||
"CREATE TABLE product (" +
|
||||
"package_name TEXT PRIMARY KEY," +
|
||||
"description TEXT NOT NULL, " +
|
||||
"data BLOB NOT NULL)"
|
||||
)
|
||||
db.execSQL("CREATE TABLE releases (package_name TEXT PRIMARY KEY, data BLOB NOT NULL)")
|
||||
db.beginTransaction()
|
||||
}
|
||||
|
||||
fun addProducts(products: List<Product>) {
|
||||
for (product in products) {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
Json.factory.createGenerator(outputStream)
|
||||
.use { it.writeDictionary(product::serialize) }
|
||||
db.insert(
|
||||
"product",
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put("package_name", product.packageName)
|
||||
put("description", product.description)
|
||||
put("data", outputStream.toByteArray())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addReleases(pairs: List<Pair<String, List<Release>>>) {
|
||||
for (pair in pairs) {
|
||||
val (packageName, releases) = pair
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
Json.factory.createGenerator(outputStream).use {
|
||||
it.writeStartArray()
|
||||
for (release in releases) {
|
||||
it.writeDictionary(release::serialize)
|
||||
}
|
||||
it.writeEndArray()
|
||||
}
|
||||
db.insert(
|
||||
"releases",
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put("package_name", packageName)
|
||||
put("data", outputStream.toByteArray())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeTransaction() {
|
||||
if (db.inTransaction()) {
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
fun forEach(repositoryId: Long, windowSize: Int, callback: (List<Product>, Int) -> Unit) {
|
||||
closeTransaction()
|
||||
db.rawQuery(
|
||||
"""SELECT product.description, product.data AS pd, releases.data AS rd FROM product
|
||||
LEFT JOIN releases ON product.package_name = releases.package_name""",
|
||||
null
|
||||
).use { cursor ->
|
||||
cursor.asSequence().map { currentCursor ->
|
||||
val description = currentCursor.getString(0)
|
||||
val product = Json.factory.createParser(currentCursor.getBlob(1)).use {
|
||||
it.nextToken()
|
||||
it.product().apply {
|
||||
this.repositoryId = repositoryId
|
||||
this.description = description
|
||||
}
|
||||
}
|
||||
val releases = currentCursor.getBlob(2)?.let { bytes ->
|
||||
Json.factory.createParser(bytes).use {
|
||||
it.nextToken()
|
||||
it.collectNotNull(
|
||||
JsonToken.START_OBJECT
|
||||
) { release() }
|
||||
}
|
||||
}.orEmpty()
|
||||
product.copy(releases = releases)
|
||||
}.windowed(windowSize, windowSize, true)
|
||||
.forEach { products -> callback(products, cursor.count) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
db.use { closeTransaction() }
|
||||
}
|
||||
|
||||
private inline fun SQLiteDatabase.execWithResult(sql: String) {
|
||||
rawQuery(sql, null).use { it.count }
|
||||
}
|
||||
}
|
||||
559
app/src/main/kotlin/com/looker/droidify/index/IndexV1Parser.kt
Normal file
559
app/src/main/kotlin/com/looker/droidify/index/IndexV1Parser.kt
Normal file
@@ -0,0 +1,559 @@
|
||||
package com.looker.droidify.index
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.core.os.ConfigurationCompat.getLocales
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.looker.droidify.utility.common.extension.Json
|
||||
import com.looker.droidify.utility.common.extension.collectDistinctNotEmptyStrings
|
||||
import com.looker.droidify.utility.common.extension.collectNotNull
|
||||
import com.looker.droidify.utility.common.extension.forEach
|
||||
import com.looker.droidify.utility.common.extension.forEachKey
|
||||
import com.looker.droidify.utility.common.extension.illegal
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Product.Donate.Bitcoin
|
||||
import com.looker.droidify.model.Product.Donate.Liberapay
|
||||
import com.looker.droidify.model.Product.Donate.Litecoin
|
||||
import com.looker.droidify.model.Product.Donate.OpenCollective
|
||||
import com.looker.droidify.model.Product.Donate.Regular
|
||||
import com.looker.droidify.model.Product.Screenshot.Type.LARGE_TABLET
|
||||
import com.looker.droidify.model.Product.Screenshot.Type.PHONE
|
||||
import com.looker.droidify.model.Product.Screenshot.Type.SMALL_TABLET
|
||||
import com.looker.droidify.model.Product.Screenshot.Type.TV
|
||||
import com.looker.droidify.model.Product.Screenshot.Type.VIDEO
|
||||
import com.looker.droidify.model.Product.Screenshot.Type.WEAR
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.nullIfEmpty
|
||||
import java.io.InputStream
|
||||
|
||||
object IndexV1Parser {
|
||||
interface Callback {
|
||||
fun onRepository(
|
||||
mirrors: List<String>,
|
||||
name: String,
|
||||
description: String,
|
||||
version: Int,
|
||||
timestamp: Long
|
||||
)
|
||||
|
||||
fun onProduct(product: Product)
|
||||
fun onReleases(packageName: String, releases: List<Release>)
|
||||
}
|
||||
|
||||
private class Screenshots(
|
||||
val video: List<String>,
|
||||
val phone: List<String>,
|
||||
val smallTablet: List<String>,
|
||||
val largeTablet: List<String>,
|
||||
val wear: List<String>,
|
||||
val tv: List<String>,
|
||||
)
|
||||
|
||||
private class Localized(
|
||||
val name: String,
|
||||
val summary: String,
|
||||
val description: String,
|
||||
val whatsNew: String,
|
||||
val metadataIcon: String,
|
||||
val screenshots: Screenshots?
|
||||
)
|
||||
|
||||
private fun <T> Map<String, Localized>.getAndCall(
|
||||
key: String,
|
||||
callback: (String, Localized) -> T?
|
||||
): T? {
|
||||
return this[key]?.let { callback(key, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the best localization for the given [localeList]
|
||||
* from collections.
|
||||
*/
|
||||
private fun <T> Map<String, T>?.getBestLocale(localeList: LocaleListCompat): T? {
|
||||
if (isNullOrEmpty()) return null
|
||||
val firstMatch = localeList.getFirstMatch(keys.toTypedArray()) ?: return null
|
||||
val tag = firstMatch.toLanguageTag()
|
||||
// try first matched tag first (usually has region tag, e.g. de-DE)
|
||||
return get(tag) ?: run {
|
||||
// split away stuff like script and try language and region only
|
||||
val langCountryTag = "${firstMatch.language}-${firstMatch.country}"
|
||||
getOrStartsWith(langCountryTag) ?: run {
|
||||
// split away region tag and try language only
|
||||
val langTag = firstMatch.language
|
||||
// try language, then English and then just take the first of the list
|
||||
getOrStartsWith(langTag) ?: get("en-US") ?: get("en") ?: values.first()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value from the map with the given key or if that key is not contained in the map,
|
||||
* tries the first map key that starts with the given key.
|
||||
* If nothing matches, null is returned.
|
||||
*
|
||||
* This is useful when looking for a language tag like `fr_CH` and falling back to `fr`
|
||||
* in a map that has `fr_FR` as a key.
|
||||
*/
|
||||
private fun <T> Map<String, T>.getOrStartsWith(s: String): T? = get(s) ?: run {
|
||||
entries.forEach { (key, value) ->
|
||||
if (key.startsWith(s)) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun <T> Map<String, Localized>.find(callback: (String, Localized) -> T?): T? {
|
||||
return getAndCall("en-US", callback)
|
||||
?: getAndCall("en_US", callback)
|
||||
?: getAndCall("en", callback)
|
||||
}
|
||||
|
||||
private fun <T> Map<String, Localized>.findLocalized(callback: (Localized) -> T?): T? {
|
||||
return getBestLocale(getLocales(Resources.getSystem().configuration))?.let { callback(it) }
|
||||
}
|
||||
|
||||
private fun Map<String, Localized>.findString(
|
||||
fallback: String,
|
||||
callback: (Localized) -> String
|
||||
): String {
|
||||
return (find { _, localized -> callback(localized).nullIfEmpty() } ?: fallback).trim()
|
||||
}
|
||||
|
||||
private fun Map<String, Localized>.findLocalizedString(
|
||||
fallback: String,
|
||||
callback: (Localized) -> String
|
||||
): String {
|
||||
// @BLumia: it's possible a key of a certain Localized object is empty, so we still need a fallback
|
||||
return (
|
||||
findLocalized { localized -> callback(localized).trim().nullIfEmpty() } ?: findString(
|
||||
fallback,
|
||||
callback
|
||||
)
|
||||
).trim()
|
||||
}
|
||||
|
||||
internal object DonateComparator : Comparator<Product.Donate> {
|
||||
private val classes = listOf(
|
||||
Regular::class,
|
||||
Bitcoin::class,
|
||||
Litecoin::class,
|
||||
Liberapay::class,
|
||||
OpenCollective::class
|
||||
)
|
||||
|
||||
override fun compare(donate1: Product.Donate, donate2: Product.Donate): Int {
|
||||
val index1 = classes.indexOf(donate1::class)
|
||||
val index2 = classes.indexOf(donate2::class)
|
||||
return when {
|
||||
index1 >= 0 && index2 == -1 -> -1
|
||||
index2 >= 0 && index1 == -1 -> 1
|
||||
else -> index1.compareTo(index2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DICT_REPO = "repo"
|
||||
private const val DICT_PRODUCT = "apps"
|
||||
private const val DICT_RELEASE = "packages"
|
||||
|
||||
private const val KEY_REPO_ADDRESS = "address"
|
||||
private const val KEY_REPO_MIRRORS = "mirrors"
|
||||
private const val KEY_REPO_NAME = "name"
|
||||
private const val KEY_REPO_DESC = "description"
|
||||
private const val KEY_REPO_VER = "version"
|
||||
private const val KEY_REPO_TIME = "timestamp"
|
||||
|
||||
fun parse(repositoryId: Long, inputStream: InputStream, callback: Callback) {
|
||||
val jsonParser = Json.factory.createParser(inputStream)
|
||||
if (jsonParser.nextToken() != JsonToken.START_OBJECT) {
|
||||
jsonParser.illegal()
|
||||
} else {
|
||||
jsonParser.forEachKey { key ->
|
||||
when {
|
||||
key.dictionary(DICT_REPO) -> {
|
||||
var address = ""
|
||||
var mirrors = emptyList<String>()
|
||||
var name = ""
|
||||
var description = ""
|
||||
var version = 0
|
||||
var timestamp = 0L
|
||||
forEachKey {
|
||||
when {
|
||||
it.string(KEY_REPO_ADDRESS) -> address = valueAsString
|
||||
it.array(KEY_REPO_MIRRORS) -> mirrors =
|
||||
collectDistinctNotEmptyStrings()
|
||||
|
||||
it.string(KEY_REPO_NAME) -> name = valueAsString
|
||||
it.string(KEY_REPO_DESC) -> description = valueAsString
|
||||
it.number(KEY_REPO_VER) -> version = valueAsInt
|
||||
it.number(KEY_REPO_TIME) -> timestamp = valueAsLong
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
val realMirrors = (
|
||||
if (address.isNotEmpty()) {
|
||||
listOf(address)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
) + mirrors
|
||||
callback.onRepository(
|
||||
mirrors = realMirrors.distinct(),
|
||||
name = name,
|
||||
description = description,
|
||||
version = version,
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
|
||||
key.array(DICT_PRODUCT) -> forEach(JsonToken.START_OBJECT) {
|
||||
val product = parseProduct(repositoryId)
|
||||
callback.onProduct(product)
|
||||
}
|
||||
|
||||
key.dictionary(DICT_RELEASE) -> forEachKey {
|
||||
if (it.token == JsonToken.START_ARRAY) {
|
||||
val packageName = it.key
|
||||
val releases = collectNotNull(JsonToken.START_OBJECT) { parseRelease() }
|
||||
callback.onReleases(packageName, releases)
|
||||
} else {
|
||||
skipChildren()
|
||||
}
|
||||
}
|
||||
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val KEY_PRODUCT_PACKAGENAME = "packageName"
|
||||
private const val KEY_PRODUCT_NAME = "name"
|
||||
private const val KEY_PRODUCT_SUMMARY = "summary"
|
||||
private const val KEY_PRODUCT_DESCRIPTION = "description"
|
||||
private const val KEY_PRODUCT_ICON = "icon"
|
||||
private const val KEY_PRODUCT_AUTHORNAME = "authorName"
|
||||
private const val KEY_PRODUCT_AUTHOREMAIL = "authorEmail"
|
||||
private const val KEY_PRODUCT_AUTHORWEBSITE = "authorWebSite"
|
||||
private const val KEY_PRODUCT_SOURCECODE = "sourceCode"
|
||||
private const val KEY_PRODUCT_CHANGELOG = "changelog"
|
||||
private const val KEY_PRODUCT_WEBSITE = "webSite"
|
||||
private const val KEY_PRODUCT_ISSUETRACKER = "issueTracker"
|
||||
private const val KEY_PRODUCT_ADDED = "added"
|
||||
private const val KEY_PRODUCT_LASTUPDATED = "lastUpdated"
|
||||
private const val KEY_PRODUCT_SUGGESTEDVERSIONCODE = "suggestedVersionCode"
|
||||
private const val KEY_PRODUCT_CATEGORIES = "categories"
|
||||
private const val KEY_PRODUCT_ANTIFEATURES = "antiFeatures"
|
||||
private const val KEY_PRODUCT_LICENSE = "license"
|
||||
private const val KEY_PRODUCT_DONATE = "donate"
|
||||
private const val KEY_PRODUCT_BITCOIN = "bitcoin"
|
||||
private const val KEY_PRODUCT_LIBERAPAYID = "liberapay"
|
||||
private const val KEY_PRODUCT_LITECOIN = "litecoin"
|
||||
private const val KEY_PRODUCT_OPENCOLLECTIVE = "openCollective"
|
||||
private const val KEY_PRODUCT_LOCALIZED = "localized"
|
||||
private const val KEY_PRODUCT_WHATSNEW = "whatsNew"
|
||||
private const val KEY_PRODUCT_PHONE_SCREENSHOTS = "phoneScreenshots"
|
||||
private const val KEY_PRODUCT_SEVEN_INCH_SCREENSHOTS = "sevenInchScreenshots"
|
||||
private const val KEY_PRODUCT_TEN_INCH_SCREENSHOTS = "tenInchScreenshots"
|
||||
private const val KEY_PRODUCT_WEAR_SCREENSHOTS = "wearScreenshots"
|
||||
private const val KEY_PRODUCT_TV_SCREENSHOTS = "tvScreenshots"
|
||||
private const val KEY_PRODUCT_VIDEO = "video"
|
||||
|
||||
private fun JsonParser.parseProduct(repositoryId: Long): Product {
|
||||
var packageName = ""
|
||||
var nameFallback = ""
|
||||
var summaryFallback = ""
|
||||
var descriptionFallback = ""
|
||||
var icon = ""
|
||||
var authorName = ""
|
||||
var authorEmail = ""
|
||||
var authorWeb = ""
|
||||
var source = ""
|
||||
var changelog = ""
|
||||
var web = ""
|
||||
var tracker = ""
|
||||
var added = 0L
|
||||
var updated = 0L
|
||||
var suggestedVersionCode = 0L
|
||||
var categories = emptyList<String>()
|
||||
var antiFeatures = emptyList<String>()
|
||||
val licenses = mutableListOf<String>()
|
||||
val donates = mutableListOf<Product.Donate>()
|
||||
val localizedMap = mutableMapOf<String, Localized>()
|
||||
forEachKey { key ->
|
||||
when {
|
||||
key.string(KEY_PRODUCT_PACKAGENAME) -> packageName = valueAsString
|
||||
key.string(KEY_PRODUCT_NAME) -> nameFallback = valueAsString
|
||||
key.string(KEY_PRODUCT_SUMMARY) -> summaryFallback = valueAsString
|
||||
key.string(KEY_PRODUCT_DESCRIPTION) -> descriptionFallback = valueAsString
|
||||
key.string(KEY_PRODUCT_ICON) -> icon = validateIcon(valueAsString)
|
||||
key.string(KEY_PRODUCT_AUTHORNAME) -> authorName = valueAsString
|
||||
key.string(KEY_PRODUCT_AUTHOREMAIL) -> authorEmail = valueAsString
|
||||
key.string(KEY_PRODUCT_AUTHORWEBSITE) -> authorWeb = valueAsString
|
||||
key.string(KEY_PRODUCT_SOURCECODE) -> source = valueAsString
|
||||
key.string(KEY_PRODUCT_CHANGELOG) -> changelog = valueAsString
|
||||
key.string(KEY_PRODUCT_WEBSITE) -> web = valueAsString
|
||||
key.string(KEY_PRODUCT_ISSUETRACKER) -> tracker = valueAsString
|
||||
key.number(KEY_PRODUCT_ADDED) -> added = valueAsLong
|
||||
key.number(KEY_PRODUCT_LASTUPDATED) -> updated = valueAsLong
|
||||
key.string(KEY_PRODUCT_SUGGESTEDVERSIONCODE) ->
|
||||
suggestedVersionCode =
|
||||
valueAsString.toLongOrNull() ?: 0L
|
||||
|
||||
key.array(KEY_PRODUCT_CATEGORIES) -> categories = collectDistinctNotEmptyStrings()
|
||||
key.array(KEY_PRODUCT_ANTIFEATURES) -> antiFeatures =
|
||||
collectDistinctNotEmptyStrings()
|
||||
|
||||
key.string(KEY_PRODUCT_LICENSE) -> licenses += valueAsString.split(',')
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
key.string(KEY_PRODUCT_DONATE) -> donates += Regular(valueAsString)
|
||||
key.string(KEY_PRODUCT_BITCOIN) -> donates += Bitcoin(valueAsString)
|
||||
key.string(KEY_PRODUCT_LIBERAPAYID) -> donates += Liberapay(valueAsString)
|
||||
key.string(KEY_PRODUCT_LITECOIN) -> donates += Litecoin(valueAsString)
|
||||
key.string(KEY_PRODUCT_OPENCOLLECTIVE) -> donates += OpenCollective(valueAsString)
|
||||
|
||||
key.dictionary(KEY_PRODUCT_LOCALIZED) -> forEachKey { localizedKey ->
|
||||
if (localizedKey.token == JsonToken.START_OBJECT) {
|
||||
val locale = localizedKey.key
|
||||
var name = ""
|
||||
var summary = ""
|
||||
var description = ""
|
||||
var whatsNew = ""
|
||||
var metadataIcon = ""
|
||||
var phone = emptyList<String>()
|
||||
var smallTablet = emptyList<String>()
|
||||
var largeTablet = emptyList<String>()
|
||||
var wear = emptyList<String>()
|
||||
var tv = emptyList<String>()
|
||||
var video = emptyList<String>()
|
||||
forEachKey {
|
||||
when {
|
||||
it.string(KEY_PRODUCT_NAME) -> name = valueAsString
|
||||
it.string(KEY_PRODUCT_SUMMARY) -> summary = valueAsString
|
||||
it.string(KEY_PRODUCT_DESCRIPTION) -> description = valueAsString
|
||||
it.string(KEY_PRODUCT_WHATSNEW) -> whatsNew = valueAsString
|
||||
it.string(KEY_PRODUCT_ICON) -> metadataIcon = valueAsString
|
||||
it.string(KEY_PRODUCT_VIDEO) -> video = listOf(valueAsString)
|
||||
it.array(KEY_PRODUCT_PHONE_SCREENSHOTS) ->
|
||||
phone = collectDistinctNotEmptyStrings()
|
||||
|
||||
it.array(KEY_PRODUCT_SEVEN_INCH_SCREENSHOTS) ->
|
||||
smallTablet = collectDistinctNotEmptyStrings()
|
||||
|
||||
it.array(KEY_PRODUCT_TEN_INCH_SCREENSHOTS) ->
|
||||
largeTablet = collectDistinctNotEmptyStrings()
|
||||
|
||||
it.array(KEY_PRODUCT_WEAR_SCREENSHOTS) ->
|
||||
wear = collectDistinctNotEmptyStrings()
|
||||
|
||||
it.array(KEY_PRODUCT_TV_SCREENSHOTS) ->
|
||||
tv = collectDistinctNotEmptyStrings()
|
||||
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
val isScreenshotEmpty =
|
||||
arrayOf(video, phone, smallTablet, largeTablet, wear, tv)
|
||||
.any { it.isNotEmpty() }
|
||||
val screenshots =
|
||||
if (isScreenshotEmpty) {
|
||||
Screenshots(video, phone, smallTablet, largeTablet, wear, tv)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
localizedMap[locale] = Localized(
|
||||
name = name,
|
||||
summary = summary,
|
||||
description = description,
|
||||
whatsNew = whatsNew,
|
||||
metadataIcon = metadataIcon.nullIfEmpty()?.let { "$locale/$it" }
|
||||
.orEmpty(),
|
||||
screenshots = screenshots,
|
||||
)
|
||||
} else {
|
||||
skipChildren()
|
||||
}
|
||||
}
|
||||
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
val name = localizedMap.findLocalizedString(nameFallback) { it.name }
|
||||
val summary = localizedMap.findLocalizedString(summaryFallback) { it.summary }
|
||||
val description =
|
||||
localizedMap.findLocalizedString(descriptionFallback) { it.description }.replace(
|
||||
"\n",
|
||||
"<br/>"
|
||||
)
|
||||
val whatsNew = localizedMap.findLocalizedString("") { it.whatsNew }.replace("\n", "<br/>")
|
||||
val metadataIcon = localizedMap.findLocalizedString("") { it.metadataIcon }.ifEmpty {
|
||||
localizedMap.firstNotNullOfOrNull { it.value.metadataIcon }.orEmpty()
|
||||
}
|
||||
val screenshotPairs =
|
||||
localizedMap.find { key, localized -> localized.screenshots?.let { Pair(key, it) } }
|
||||
val screenshots = screenshotPairs?.let { (key, screenshots) ->
|
||||
screenshots.video.map { Product.Screenshot(key, VIDEO, it) } +
|
||||
screenshots.phone.map { Product.Screenshot(key, PHONE, it) } +
|
||||
screenshots.smallTablet.map { Product.Screenshot(key, SMALL_TABLET, it) } +
|
||||
screenshots.largeTablet.map { Product.Screenshot(key, LARGE_TABLET, it) } +
|
||||
screenshots.wear.map { Product.Screenshot(key, WEAR, it) } +
|
||||
screenshots.tv.map { Product.Screenshot(key, TV, it) }
|
||||
}.orEmpty()
|
||||
return Product(
|
||||
repositoryId = repositoryId,
|
||||
packageName = packageName,
|
||||
name = name,
|
||||
summary = summary,
|
||||
description = description,
|
||||
whatsNew = whatsNew,
|
||||
icon = icon,
|
||||
metadataIcon = metadataIcon,
|
||||
author = Product.Author(authorName, authorEmail, authorWeb),
|
||||
source = source,
|
||||
changelog = changelog,
|
||||
web = web,
|
||||
tracker = tracker,
|
||||
added = added,
|
||||
updated = updated,
|
||||
suggestedVersionCode = suggestedVersionCode,
|
||||
categories = categories,
|
||||
antiFeatures = antiFeatures,
|
||||
licenses = licenses,
|
||||
donates = donates.sortedWith(DonateComparator),
|
||||
screenshots = screenshots,
|
||||
releases = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private const val KEY_RELEASE_VERSIONNAME = "versionName"
|
||||
private const val KEY_RELEASE_VERSIONCODE = "versionCode"
|
||||
private const val KEY_RELEASE_ADDED = "added"
|
||||
private const val KEY_RELEASE_SIZE = "size"
|
||||
private const val KEY_RELEASE_MINSDKVERSION = "minSdkVersion"
|
||||
private const val KEY_RELEASE_TARGETSDKVERSION = "targetSdkVersion"
|
||||
private const val KEY_RELEASE_MAXSDKVERSION = "maxSdkVersion"
|
||||
private const val KEY_RELEASE_SRCNAME = "srcname"
|
||||
private const val KEY_RELEASE_APKNAME = "apkName"
|
||||
private const val KEY_RELEASE_HASH = "hash"
|
||||
private const val KEY_RELEASE_HASHTYPE = "hashType"
|
||||
private const val KEY_RELEASE_SIG = "sig"
|
||||
private const val KEY_RELEASE_OBBMAINFILE = "obbMainFile"
|
||||
private const val KEY_RELEASE_OBBMAINFILESHA256 = "obbMainFileSha256"
|
||||
private const val KEY_RELEASE_OBBPATCHFILE = "obbPatchFile"
|
||||
private const val KEY_RELEASE_OBBPATCHFILESHA256 = "obbPatchFileSha256"
|
||||
private const val KEY_RELEASE_USESPERMISSION = "uses-permission"
|
||||
private const val KEY_RELEASE_USESPERMISSIONSDK23 = "uses-permission-sdk-23"
|
||||
private const val KEY_RELEASE_FEATURES = "features"
|
||||
private const val KEY_RELEASE_NATIVECODE = "nativecode"
|
||||
|
||||
private fun JsonParser.parseRelease(): Release {
|
||||
var version = ""
|
||||
var versionCode = 0L
|
||||
var added = 0L
|
||||
var size = 0L
|
||||
var minSdkVersion = 0
|
||||
var targetSdkVersion = 0
|
||||
var maxSdkVersion = 0
|
||||
var source = ""
|
||||
var release = ""
|
||||
var hash = ""
|
||||
var hashTypeCandidate = ""
|
||||
var signature = ""
|
||||
var obbMain = ""
|
||||
var obbMainHash = ""
|
||||
var obbPatch = ""
|
||||
var obbPatchHash = ""
|
||||
val permissions = linkedSetOf<String>()
|
||||
var features = emptyList<String>()
|
||||
var platforms = emptyList<String>()
|
||||
forEachKey { key ->
|
||||
when {
|
||||
key.string(KEY_RELEASE_VERSIONNAME) -> version = valueAsString
|
||||
key.number(KEY_RELEASE_VERSIONCODE) -> versionCode = valueAsLong
|
||||
key.number(KEY_RELEASE_ADDED) -> added = valueAsLong
|
||||
key.number(KEY_RELEASE_SIZE) -> size = valueAsLong
|
||||
key.number(KEY_RELEASE_MINSDKVERSION) -> minSdkVersion = valueAsInt
|
||||
key.number(KEY_RELEASE_TARGETSDKVERSION) -> targetSdkVersion = valueAsInt
|
||||
key.number(KEY_RELEASE_MAXSDKVERSION) -> maxSdkVersion = valueAsInt
|
||||
key.string(KEY_RELEASE_SRCNAME) -> source = valueAsString
|
||||
key.string(KEY_RELEASE_APKNAME) -> release = valueAsString
|
||||
key.string(KEY_RELEASE_HASH) -> hash = valueAsString
|
||||
key.string(KEY_RELEASE_HASHTYPE) -> hashTypeCandidate = valueAsString
|
||||
key.string(KEY_RELEASE_SIG) -> signature = valueAsString
|
||||
key.string(KEY_RELEASE_OBBMAINFILE) -> obbMain = valueAsString
|
||||
key.string(KEY_RELEASE_OBBMAINFILESHA256) -> obbMainHash = valueAsString
|
||||
key.string(KEY_RELEASE_OBBPATCHFILE) -> obbPatch = valueAsString
|
||||
key.string(KEY_RELEASE_OBBPATCHFILESHA256) -> obbPatchHash = valueAsString
|
||||
key.array(KEY_RELEASE_USESPERMISSION) -> collectPermissions(permissions, 0)
|
||||
key.array(KEY_RELEASE_USESPERMISSIONSDK23) -> collectPermissions(permissions, 23)
|
||||
key.array(KEY_RELEASE_FEATURES) -> features = collectDistinctNotEmptyStrings()
|
||||
key.array(KEY_RELEASE_NATIVECODE) -> platforms = collectDistinctNotEmptyStrings()
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
val hashType =
|
||||
if (hash.isNotEmpty() && hashTypeCandidate.isEmpty()) "sha256" else hashTypeCandidate
|
||||
val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else ""
|
||||
val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else ""
|
||||
return Release(
|
||||
selected = false,
|
||||
version = version,
|
||||
versionCode = versionCode,
|
||||
added = added,
|
||||
size = size,
|
||||
minSdkVersion = minSdkVersion,
|
||||
targetSdkVersion = targetSdkVersion,
|
||||
maxSdkVersion = maxSdkVersion,
|
||||
source = source,
|
||||
release = release,
|
||||
hash = hash,
|
||||
hashType = hashType,
|
||||
signature = signature,
|
||||
obbMain = obbMain,
|
||||
obbMainHash = obbMainHash,
|
||||
obbMainHashType = obbMainHashType,
|
||||
obbPatch = obbPatch,
|
||||
obbPatchHash = obbPatchHash,
|
||||
obbPatchHashType = obbPatchHashType,
|
||||
permissions = permissions.toList(),
|
||||
features = features,
|
||||
platforms = platforms,
|
||||
incompatibilities = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonParser.collectPermissions(permissions: LinkedHashSet<String>, minSdk: Int) {
|
||||
forEach(JsonToken.START_ARRAY) {
|
||||
val firstToken = nextToken()
|
||||
val permission = if (firstToken == JsonToken.VALUE_STRING) valueAsString else ""
|
||||
if (firstToken != JsonToken.END_ARRAY) {
|
||||
val secondToken = nextToken()
|
||||
val maxSdk = if (secondToken == JsonToken.VALUE_NUMBER_INT) valueAsInt else 0
|
||||
if (permission.isNotEmpty() &&
|
||||
SdkCheck.sdk >= minSdk && (
|
||||
maxSdk <= 0 ||
|
||||
SdkCheck.sdk <= maxSdk
|
||||
)
|
||||
) {
|
||||
permissions.add(permission)
|
||||
}
|
||||
if (secondToken != JsonToken.END_ARRAY) {
|
||||
while (true) {
|
||||
val token = nextToken()
|
||||
if (token == JsonToken.END_ARRAY) {
|
||||
break
|
||||
} else if (token.isStructStart) {
|
||||
skipChildren()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateIcon(icon: String): String {
|
||||
return if (icon.endsWith(".xml")) "" else icon
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package com.looker.droidify.index
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.domain.model.fingerprint
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.network.Downloader
|
||||
import com.looker.droidify.network.NetworkResponse
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.extension.toFormattedString
|
||||
import com.looker.droidify.utility.common.result.Result
|
||||
import com.looker.droidify.utility.extension.android.Android
|
||||
import com.looker.droidify.utility.getProgress
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.File
|
||||
import java.security.CodeSigner
|
||||
import java.security.cert.Certificate
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarFile
|
||||
|
||||
object RepositoryUpdater {
|
||||
enum class Stage {
|
||||
DOWNLOAD, PROCESS, MERGE, COMMIT
|
||||
}
|
||||
|
||||
// TODO Add support for Index-V2 and also cleanup everything here
|
||||
enum class IndexType(
|
||||
val jarName: String,
|
||||
val contentName: String
|
||||
) {
|
||||
INDEX_V1("index-v1.jar", "index-v1.json")
|
||||
}
|
||||
|
||||
enum class ErrorType {
|
||||
NETWORK, HTTP, VALIDATION, PARSING
|
||||
}
|
||||
|
||||
class UpdateException : Exception {
|
||||
val errorType: ErrorType
|
||||
|
||||
constructor(errorType: ErrorType, message: String) : super(message) {
|
||||
this.errorType = errorType
|
||||
}
|
||||
|
||||
constructor(errorType: ErrorType, message: String, cause: Exception) : super(
|
||||
message,
|
||||
cause
|
||||
) {
|
||||
this.errorType = errorType
|
||||
}
|
||||
}
|
||||
|
||||
private val updaterLock = Any()
|
||||
private val cleanupLock = Any()
|
||||
|
||||
private lateinit var downloader: Downloader
|
||||
|
||||
fun init(scope: CoroutineScope, downloader: Downloader) {
|
||||
this.downloader = downloader
|
||||
scope.launch {
|
||||
// No need of mutex because it is in same coroutine scope
|
||||
var lastDisabled = emptyMap<Long, Boolean>()
|
||||
Database.RepositoryAdapter
|
||||
.getAllRemovedStream()
|
||||
.map { deletedRepos ->
|
||||
deletedRepos
|
||||
.filterNot { it.key in lastDisabled.keys }
|
||||
.also { lastDisabled = deletedRepos }
|
||||
}
|
||||
// To not perform complete cleanup on startup
|
||||
.drop(1)
|
||||
.filter { it.isNotEmpty() }
|
||||
.collect(Database.RepositoryAdapter::cleanup)
|
||||
}
|
||||
}
|
||||
|
||||
fun await() {
|
||||
synchronized(updaterLock) { }
|
||||
}
|
||||
|
||||
suspend fun update(
|
||||
context: Context,
|
||||
repository: Repository,
|
||||
unstable: Boolean,
|
||||
callback: (Stage, Long, Long?) -> Unit
|
||||
) = update(
|
||||
context = context,
|
||||
repository = repository,
|
||||
unstable = unstable,
|
||||
indexTypes = listOf(IndexType.INDEX_V1),
|
||||
callback = callback
|
||||
)
|
||||
|
||||
private suspend fun update(
|
||||
context: Context,
|
||||
repository: Repository,
|
||||
unstable: Boolean,
|
||||
indexTypes: List<IndexType>,
|
||||
callback: (Stage, Long, Long?) -> Unit
|
||||
): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||
val indexType = indexTypes[0]
|
||||
when (val request = downloadIndex(context, repository, indexType, callback)) {
|
||||
is Result.Error -> {
|
||||
val result = request.data
|
||||
?: return@withContext Result.Error(request.exception, false)
|
||||
|
||||
val file = request.data?.file
|
||||
?: return@withContext Result.Error(request.exception, false)
|
||||
file.delete()
|
||||
if (result.statusCode == 404 && indexTypes.isNotEmpty()) {
|
||||
update(
|
||||
context = context,
|
||||
repository = repository,
|
||||
indexTypes = indexTypes.subList(1, indexTypes.size),
|
||||
unstable = unstable,
|
||||
callback = callback
|
||||
)
|
||||
} else {
|
||||
Result.Error(
|
||||
UpdateException(
|
||||
ErrorType.HTTP,
|
||||
"Invalid response: HTTP ${result.statusCode}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is Result.Success -> {
|
||||
if (request.data.isUnmodified) {
|
||||
request.data.file.delete()
|
||||
Result.Success(false)
|
||||
} else {
|
||||
try {
|
||||
val isFileParsedSuccessfully = processFile(
|
||||
context = context,
|
||||
repository = repository,
|
||||
indexType = indexType,
|
||||
unstable = unstable,
|
||||
file = request.data.file,
|
||||
lastModified = request.data.lastModified,
|
||||
entityTag = request.data.entityTag,
|
||||
callback = callback
|
||||
)
|
||||
Result.Success(isFileParsedSuccessfully)
|
||||
} catch (e: UpdateException) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadIndex(
|
||||
context: Context,
|
||||
repository: Repository,
|
||||
indexType: IndexType,
|
||||
callback: (Stage, Long, Long?) -> Unit
|
||||
): Result<IndexFile> = withContext(Dispatchers.IO) {
|
||||
val file = Cache.getTemporaryFile(context)
|
||||
val result = downloader.downloadToFile(
|
||||
url = Uri.parse(repository.address).buildUpon()
|
||||
.appendPath(indexType.jarName).build().toString(),
|
||||
target = file,
|
||||
headers = {
|
||||
ifModifiedSince(repository.lastModified)
|
||||
etag(repository.entityTag)
|
||||
authentication(repository.authentication)
|
||||
}
|
||||
) { read, total ->
|
||||
callback(Stage.DOWNLOAD, read.value, total.value.takeIf { it != 0L })
|
||||
}
|
||||
|
||||
when (result) {
|
||||
is NetworkResponse.Success -> {
|
||||
Result.Success(
|
||||
IndexFile(
|
||||
isUnmodified = result.statusCode == 304,
|
||||
lastModified = result.lastModified?.toFormattedString() ?: "",
|
||||
entityTag = result.etag ?: "",
|
||||
statusCode = result.statusCode,
|
||||
file = file
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is NetworkResponse.Error -> {
|
||||
file.delete()
|
||||
when (result) {
|
||||
is NetworkResponse.Error.Http -> {
|
||||
val errorType = if (result.statusCode in 400..499) {
|
||||
ErrorType.HTTP
|
||||
} else {
|
||||
ErrorType.NETWORK
|
||||
}
|
||||
|
||||
Result.Error(
|
||||
UpdateException(
|
||||
errorType = errorType,
|
||||
message = "Failed with Status: ${result.statusCode}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is NetworkResponse.Error.ConnectionTimeout -> Result.Error(result.exception)
|
||||
is NetworkResponse.Error.IO -> Result.Error(result.exception)
|
||||
is NetworkResponse.Error.SocketTimeout -> Result.Error(result.exception)
|
||||
is NetworkResponse.Error.Unknown -> Result.Error(result.exception)
|
||||
// TODO: Add Validator
|
||||
is NetworkResponse.Error.Validation -> Result.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun processFile(
|
||||
context: Context,
|
||||
repository: Repository,
|
||||
indexType: IndexType,
|
||||
unstable: Boolean,
|
||||
file: File,
|
||||
mergerFile: File = Cache.getTemporaryFile(context),
|
||||
lastModified: String,
|
||||
entityTag: String,
|
||||
callback: (Stage, Long, Long?) -> Unit
|
||||
): Boolean {
|
||||
var rollback = true
|
||||
return synchronized(updaterLock) {
|
||||
try {
|
||||
val jarFile = JarFile(file, true)
|
||||
val indexEntry = jarFile.getEntry(indexType.contentName) as JarEntry
|
||||
val total = indexEntry.size
|
||||
Database.UpdaterAdapter.createTemporaryTable()
|
||||
val features = context.packageManager.systemAvailableFeatures
|
||||
.asSequence().map { it.name }.toSet() + setOf("android.hardware.touchscreen")
|
||||
|
||||
var changedRepository: Repository? = null
|
||||
|
||||
try {
|
||||
val unmergedProducts = mutableListOf<Product>()
|
||||
val unmergedReleases = mutableListOf<Pair<String, List<Release>>>()
|
||||
IndexMerger(mergerFile).use { indexMerger ->
|
||||
jarFile.getInputStream(indexEntry).getProgress {
|
||||
callback(Stage.PROCESS, it, total)
|
||||
}.use { entryStream ->
|
||||
IndexV1Parser.parse(
|
||||
repository.id,
|
||||
entryStream,
|
||||
object : IndexV1Parser.Callback {
|
||||
override fun onRepository(
|
||||
mirrors: List<String>,
|
||||
name: String,
|
||||
description: String,
|
||||
version: Int,
|
||||
timestamp: Long
|
||||
) {
|
||||
changedRepository = repository.update(
|
||||
mirrors,
|
||||
name,
|
||||
description,
|
||||
version,
|
||||
lastModified,
|
||||
entityTag,
|
||||
timestamp
|
||||
)
|
||||
}
|
||||
|
||||
override fun onProduct(product: Product) {
|
||||
if (Thread.interrupted()) {
|
||||
throw InterruptedException()
|
||||
}
|
||||
unmergedProducts += product
|
||||
if (unmergedProducts.size >= 50) {
|
||||
indexMerger.addProducts(unmergedProducts)
|
||||
unmergedProducts.clear()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReleases(
|
||||
packageName: String,
|
||||
releases: List<Release>
|
||||
) {
|
||||
if (Thread.interrupted()) {
|
||||
throw InterruptedException()
|
||||
}
|
||||
unmergedReleases += Pair(packageName, releases)
|
||||
if (unmergedReleases.size >= 50) {
|
||||
indexMerger.addReleases(unmergedReleases)
|
||||
unmergedReleases.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (Thread.interrupted()) {
|
||||
throw InterruptedException()
|
||||
}
|
||||
if (unmergedProducts.isNotEmpty()) {
|
||||
indexMerger.addProducts(unmergedProducts)
|
||||
unmergedProducts.clear()
|
||||
}
|
||||
if (unmergedReleases.isNotEmpty()) {
|
||||
indexMerger.addReleases(unmergedReleases)
|
||||
unmergedReleases.clear()
|
||||
}
|
||||
var progress = 0
|
||||
indexMerger.forEach(repository.id, 50) { products, totalCount ->
|
||||
if (Thread.interrupted()) {
|
||||
throw InterruptedException()
|
||||
}
|
||||
progress += products.size
|
||||
callback(
|
||||
Stage.MERGE,
|
||||
progress.toLong(),
|
||||
totalCount.toLong()
|
||||
)
|
||||
Database.UpdaterAdapter.putTemporary(
|
||||
products
|
||||
.map { transformProduct(it, features, unstable) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
mergerFile.delete()
|
||||
}
|
||||
|
||||
val workRepository = changedRepository ?: repository
|
||||
if (workRepository.timestamp < repository.timestamp) {
|
||||
throw UpdateException(
|
||||
ErrorType.VALIDATION,
|
||||
"New index is older than current index:" +
|
||||
" ${workRepository.timestamp} < ${repository.timestamp}"
|
||||
)
|
||||
}
|
||||
|
||||
val fingerprint = indexEntry
|
||||
.codeSigner
|
||||
.certificate
|
||||
.fingerprint()
|
||||
.toString()
|
||||
.uppercase()
|
||||
|
||||
val commitRepository = if (!workRepository.fingerprint.equals(
|
||||
fingerprint,
|
||||
ignoreCase = true
|
||||
)
|
||||
) {
|
||||
if (workRepository.fingerprint.isNotEmpty()) {
|
||||
throw UpdateException(
|
||||
ErrorType.VALIDATION,
|
||||
"Certificate fingerprints do not match"
|
||||
)
|
||||
}
|
||||
|
||||
workRepository.copy(fingerprint = fingerprint)
|
||||
} else {
|
||||
workRepository
|
||||
}
|
||||
if (Thread.interrupted()) {
|
||||
throw InterruptedException()
|
||||
}
|
||||
callback(Stage.COMMIT, 0, null)
|
||||
synchronized(cleanupLock) {
|
||||
Database.UpdaterAdapter.finishTemporary(commitRepository, true)
|
||||
}
|
||||
rollback = false
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
throw when (e) {
|
||||
is UpdateException, is InterruptedException -> e
|
||||
else -> UpdateException(ErrorType.PARSING, "Error parsing index", e)
|
||||
}
|
||||
} finally {
|
||||
file.delete()
|
||||
if (rollback) {
|
||||
Database.UpdaterAdapter.finishTemporary(repository, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@get:Throws(UpdateException::class)
|
||||
private val JarEntry.codeSigner: CodeSigner
|
||||
get() = codeSigners?.singleOrNull()
|
||||
?: throw UpdateException(
|
||||
ErrorType.VALIDATION,
|
||||
"index.jar must be signed by a single code signer"
|
||||
)
|
||||
|
||||
@get:Throws(UpdateException::class)
|
||||
private val CodeSigner.certificate: Certificate
|
||||
get() = signerCertPath?.certificates?.singleOrNull()
|
||||
?: throw UpdateException(
|
||||
ErrorType.VALIDATION,
|
||||
"index.jar code signer should have only one certificate"
|
||||
)
|
||||
|
||||
private fun transformProduct(
|
||||
product: Product,
|
||||
features: Set<String>,
|
||||
unstable: Boolean
|
||||
): Product {
|
||||
val releasePairs = product.releases
|
||||
.distinctBy { it.identifier }
|
||||
.sortedByDescending { it.versionCode }
|
||||
.map { release ->
|
||||
val incompatibilities = mutableListOf<Release.Incompatibility>()
|
||||
if (release.minSdkVersion > 0 && SdkCheck.sdk < release.minSdkVersion) {
|
||||
incompatibilities += Release.Incompatibility.MinSdk
|
||||
}
|
||||
if (release.maxSdkVersion > 0 && SdkCheck.sdk > release.maxSdkVersion) {
|
||||
incompatibilities += Release.Incompatibility.MaxSdk
|
||||
}
|
||||
if (release.platforms.isNotEmpty() &&
|
||||
(release.platforms intersect Android.platforms).isEmpty()
|
||||
) {
|
||||
incompatibilities += Release.Incompatibility.Platform
|
||||
}
|
||||
incompatibilities += (release.features - features).sorted()
|
||||
.map { Release.Incompatibility.Feature(it) }
|
||||
Pair(release, incompatibilities.toList())
|
||||
}
|
||||
|
||||
val predicate: (Release) -> Boolean = {
|
||||
unstable ||
|
||||
product.suggestedVersionCode <= 0 ||
|
||||
it.versionCode <= product.suggestedVersionCode
|
||||
}
|
||||
|
||||
val firstSelected =
|
||||
releasePairs.firstOrNull { it.second.isEmpty() && predicate(it.first) }
|
||||
?: releasePairs.firstOrNull { predicate(it.first) }
|
||||
|
||||
val releases = releasePairs
|
||||
.map { (release, incompatibilities) ->
|
||||
release.copy(
|
||||
incompatibilities = incompatibilities,
|
||||
selected = firstSelected?.let {
|
||||
it.first.versionCode == release.versionCode &&
|
||||
it.second == incompatibilities
|
||||
} ?: false
|
||||
)
|
||||
}
|
||||
return product.copy(releases = releases)
|
||||
}
|
||||
}
|
||||
|
||||
data class IndexFile(
|
||||
val isUnmodified: Boolean,
|
||||
val lastModified: String,
|
||||
val entityTag: String,
|
||||
val statusCode: Int,
|
||||
val file: File
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.looker.droidify.installer
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.utility.common.extension.addAndCompute
|
||||
import com.looker.droidify.utility.common.extension.filter
|
||||
import com.looker.droidify.utility.common.extension.notificationManager
|
||||
import com.looker.droidify.utility.common.extension.updateAsMutable
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.get
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.installer.installers.Installer
|
||||
import com.looker.droidify.installer.installers.LegacyInstaller
|
||||
import com.looker.droidify.installer.installers.root.RootInstaller
|
||||
import com.looker.droidify.installer.installers.session.SessionInstaller
|
||||
import com.looker.droidify.installer.installers.shizuku.ShizukuInstaller
|
||||
import com.looker.droidify.installer.model.InstallItem
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.installer.notification.createInstallNotification
|
||||
import com.looker.droidify.installer.notification.installNotification
|
||||
import com.looker.droidify.installer.notification.removeInstallNotification
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class InstallManager(
|
||||
private val context: Context,
|
||||
settingsRepository: SettingsRepository
|
||||
) {
|
||||
|
||||
private val installItems = Channel<InstallItem>()
|
||||
private val uninstallItems = Channel<PackageName>()
|
||||
|
||||
val state = MutableStateFlow<Map<PackageName, InstallState>>(emptyMap())
|
||||
|
||||
private var _installer: Installer? = null
|
||||
set(value) {
|
||||
field?.close()
|
||||
field = value
|
||||
}
|
||||
private val installer: Installer get() = _installer!!
|
||||
|
||||
private val lock = Mutex()
|
||||
private val installerPreference = settingsRepository.get { installerType }
|
||||
|
||||
suspend operator fun invoke() = coroutineScope {
|
||||
setupInstaller()
|
||||
installer()
|
||||
uninstaller()
|
||||
}
|
||||
|
||||
fun close() {
|
||||
_installer = null
|
||||
uninstallItems.close()
|
||||
installItems.close()
|
||||
}
|
||||
|
||||
suspend infix fun install(installItem: InstallItem) {
|
||||
installItems.send(installItem)
|
||||
}
|
||||
|
||||
suspend infix fun uninstall(packageName: PackageName) {
|
||||
uninstallItems.send(packageName)
|
||||
}
|
||||
|
||||
infix fun remove(packageName: PackageName) {
|
||||
updateState { remove(packageName) }
|
||||
}
|
||||
|
||||
private fun CoroutineScope.setupInstaller() = launch {
|
||||
installerPreference.collectLatest(::setInstaller)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.installer() = launch {
|
||||
val currentQueue = mutableSetOf<String>()
|
||||
installItems.filter { item ->
|
||||
currentQueue.addAndCompute(item.packageName.name) { isAdded ->
|
||||
if (isAdded) {
|
||||
updateState { put(item.packageName, InstallState.Pending) }
|
||||
}
|
||||
}
|
||||
}.consumeEach { item ->
|
||||
if (state.value.containsKey(item.packageName)) {
|
||||
updateState { put(item.packageName, InstallState.Installing) }
|
||||
context.notificationManager?.installNotification(
|
||||
packageName = item.packageName.name,
|
||||
notification = context.createInstallNotification(
|
||||
appName = item.packageName.name,
|
||||
state = InstallState.Installing,
|
||||
)
|
||||
)
|
||||
val success = installer.use {
|
||||
it.install(item)
|
||||
}
|
||||
context.notificationManager?.removeInstallNotification(item.packageName.name)
|
||||
updateState { put(item.packageName, success) }
|
||||
currentQueue.remove(item.packageName.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.uninstaller() = launch {
|
||||
uninstallItems.consumeEach {
|
||||
installer.uninstall(it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setInstaller(installerType: InstallerType) {
|
||||
lock.withLock {
|
||||
_installer = when (installerType) {
|
||||
InstallerType.LEGACY -> LegacyInstaller(context)
|
||||
InstallerType.SESSION -> SessionInstaller(context)
|
||||
InstallerType.SHIZUKU -> ShizukuInstaller(context)
|
||||
InstallerType.ROOT -> RootInstaller(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun updateState(block: MutableMap<PackageName, InstallState>.() -> Unit) {
|
||||
state.update { it.updateAsMutable(block) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.looker.droidify.installer.installers
|
||||
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.installer.model.InstallItem
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
|
||||
interface Installer : AutoCloseable {
|
||||
|
||||
suspend fun install(installItem: InstallItem): InstallState
|
||||
|
||||
suspend fun uninstall(packageName: PackageName)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.looker.droidify.installer.installers
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import com.looker.droidify.utility.common.extension.getLauncherActivities
|
||||
import com.looker.droidify.utility.common.extension.getPackageInfoCompat
|
||||
import com.looker.droidify.utility.common.extension.intent
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import rikka.shizuku.ShizukuProvider
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 87263
|
||||
|
||||
fun launchShizuku(context: Context) {
|
||||
val activities =
|
||||
context.packageManager.getLauncherActivities(ShizukuProvider.MANAGER_APPLICATION_ID)
|
||||
val intent = intent(Intent.ACTION_MAIN) {
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
setComponent(
|
||||
ComponentName(
|
||||
ShizukuProvider.MANAGER_APPLICATION_ID,
|
||||
activities.first().first
|
||||
)
|
||||
)
|
||||
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun isShizukuInstalled(context: Context) =
|
||||
context.packageManager.getPackageInfoCompat(ShizukuProvider.MANAGER_APPLICATION_ID) != null
|
||||
|
||||
fun isShizukuAlive() = rikka.shizuku.Shizuku.pingBinder()
|
||||
|
||||
fun isShizukuGranted() = rikka.shizuku.Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
suspend fun requestPermissionListener() = suspendCancellableCoroutine {
|
||||
val listener = rikka.shizuku.Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
|
||||
if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) {
|
||||
it.resume(grantResult == PackageManager.PERMISSION_GRANTED)
|
||||
}
|
||||
}
|
||||
rikka.shizuku.Shizuku.addRequestPermissionResultListener(listener)
|
||||
rikka.shizuku.Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
|
||||
it.invokeOnCancellation {
|
||||
rikka.shizuku.Shizuku.removeRequestPermissionResultListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestShizuku() {
|
||||
rikka.shizuku.Shizuku.shouldShowRequestPermissionRationale()
|
||||
rikka.shizuku.Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
|
||||
fun isMagiskGranted(): Boolean {
|
||||
com.topjohnwu.superuser.Shell.getCachedShell() ?: com.topjohnwu.superuser.Shell.getShell()
|
||||
return com.topjohnwu.superuser.Shell.isAppGrantedRoot() == true
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.looker.droidify.installer.installers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AndroidRuntimeException
|
||||
import androidx.core.net.toUri
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.installer.model.InstallItem
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.extension.intent
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class LegacyInstaller(private val context: Context) : Installer {
|
||||
|
||||
companion object {
|
||||
private const val APK_MIME = "application/vnd.android.package-archive"
|
||||
}
|
||||
|
||||
override suspend fun install(
|
||||
installItem: InstallItem,
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
val installFlag = if (SdkCheck.isNougat) Intent.FLAG_GRANT_READ_URI_PERMISSION else 0
|
||||
val fileUri = if (SdkCheck.isNougat) {
|
||||
Cache.getReleaseUri(
|
||||
context,
|
||||
installItem.installFileName
|
||||
)
|
||||
} else {
|
||||
Cache.getReleaseFile(context, installItem.installFileName).toUri()
|
||||
}
|
||||
val installIntent = intent(Intent.ACTION_INSTALL_PACKAGE) {
|
||||
setDataAndType(fileUri, APK_MIME)
|
||||
flags = installFlag
|
||||
}
|
||||
try {
|
||||
context.startActivity(installIntent)
|
||||
cont.resume(InstallState.Installed)
|
||||
} catch (e: AndroidRuntimeException) {
|
||||
installIntent.flags = installFlag or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
context.startActivity(installIntent)
|
||||
cont.resume(InstallState.Installed)
|
||||
} catch (e: Exception) {
|
||||
cont.resume(InstallState.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uninstall(packageName: PackageName) =
|
||||
context.uninstallPackage(packageName)
|
||||
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
suspend fun Context.uninstallPackage(packageName: PackageName) =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
try {
|
||||
startActivity(
|
||||
intent(Intent.ACTION_UNINSTALL_PACKAGE) {
|
||||
data = "package:${packageName.name}".toUri()
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
)
|
||||
cont.resume(Unit)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
cont.resume(Unit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.looker.droidify.installer.installers.root
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.installer.installers.Installer
|
||||
import com.looker.droidify.installer.installers.uninstallPackage
|
||||
import com.looker.droidify.installer.model.InstallItem
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class RootInstaller(private val context: Context) : Installer {
|
||||
|
||||
override suspend fun install(
|
||||
installItem: InstallItem,
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
val releaseFile = Cache.getReleaseFile(context, installItem.installFileName)
|
||||
val installCommand = INSTALL_COMMAND.format(
|
||||
releaseFile.absolutePath,
|
||||
currentUser(),
|
||||
releaseFile.length(),
|
||||
)
|
||||
Shell.cmd(installCommand).submit { shellResult ->
|
||||
val result = if (shellResult.isSuccess) InstallState.Installed
|
||||
else InstallState.Failed
|
||||
cont.resume(result)
|
||||
val deleteCommand = DELETE_COMMAND.format(utilBox(), releaseFile.absolutePath)
|
||||
Shell.cmd(deleteCommand).submit()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uninstall(packageName: PackageName) =
|
||||
context.uninstallPackage(packageName)
|
||||
|
||||
override fun close() {}
|
||||
|
||||
}
|
||||
|
||||
private const val INSTALL_COMMAND = "cat %s | pm install --user %s -t -r -S %s"
|
||||
private const val DELETE_COMMAND = "%s rm %s"
|
||||
|
||||
/** Returns the path of either toybox or busybox, or empty string if not found. */
|
||||
private fun utilBox(): String {
|
||||
listOf("toybox", "busybox").forEach {
|
||||
// Returns the path of the requested [command], or empty string if not found
|
||||
val out = Shell.cmd("which $it").exec().out
|
||||
if (out.isEmpty()) return ""
|
||||
if (out.first().contains("not found")) return ""
|
||||
return out.first()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/** Returns the current user of the device. */
|
||||
private fun currentUser() = if (SdkCheck.isOreo) {
|
||||
Shell.cmd("am get-current-user")
|
||||
.exec()
|
||||
.out[0]
|
||||
} else {
|
||||
Shell.cmd("dumpsys activity | grep -E \"mUserLru\"")
|
||||
.exec()
|
||||
.out[0]
|
||||
.trim()
|
||||
.removePrefix("mUserLru: [")
|
||||
.removeSuffix("]")
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.looker.droidify.installer.installers.session
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
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 android.util.Log
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.log
|
||||
import com.looker.droidify.utility.common.sdkAbove
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.installer.installers.Installer
|
||||
import com.looker.droidify.installer.model.InstallItem
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class SessionInstaller(private val context: Context) : Installer {
|
||||
|
||||
private val installer = context.packageManager.packageInstaller
|
||||
private val intent = Intent(context, SessionInstallerReceiver::class.java)
|
||||
|
||||
companion object {
|
||||
private var installerCallbacks: PackageInstaller.SessionCallback? = null
|
||||
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)
|
||||
}
|
||||
sdkAbove(sdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
setRequestUpdateOwnership(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun install(
|
||||
installItem: InstallItem
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
val cacheFile = Cache.getReleaseFile(context, installItem.installFileName)
|
||||
val id = installer.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(InstallState.Installed)
|
||||
}
|
||||
}
|
||||
installerCallbacks = installerCallback
|
||||
|
||||
installer.registerSessionCallback(
|
||||
installerCallbacks!!,
|
||||
Handler(Looper.getMainLooper())
|
||||
)
|
||||
|
||||
val session = installer.openSession(id)
|
||||
|
||||
session.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.getBroadcast(context, id, intent, flags)
|
||||
|
||||
if (cont.isActive) activeSession.commit(pendingIntent.intentSender)
|
||||
}
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
try {
|
||||
installer.abandonSession(id)
|
||||
} catch (e: SecurityException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun uninstall(packageName: PackageName) =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
intent.putExtra(SessionInstallerReceiver.ACTION_UNINSTALL, true)
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, -1, intent, flags)
|
||||
|
||||
installer.uninstall(packageName.name, pendingIntent.intentSender)
|
||||
cont.resume(Unit)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
installerCallbacks?.let {
|
||||
installer.unregisterSessionCallback(it)
|
||||
installerCallbacks = null
|
||||
}
|
||||
try {
|
||||
installer.mySessions.forEach { installer.abandonSession(it.sessionId) }
|
||||
} catch (e: SecurityException) {
|
||||
log(e.message, type = Log.ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.looker.droidify.installer.installers.session
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import com.looker.droidify.utility.common.Constants.NOTIFICATION_CHANNEL_INSTALL
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.utility.common.createNotificationChannel
|
||||
import com.looker.droidify.utility.common.extension.getPackageName
|
||||
import com.looker.droidify.utility.common.extension.notificationManager
|
||||
import com.looker.droidify.domain.model.toPackageName
|
||||
import com.looker.droidify.installer.InstallManager
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.installer.notification.createInstallNotification
|
||||
import com.looker.droidify.installer.notification.installNotification
|
||||
import com.looker.droidify.installer.notification.removeInstallNotification
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SessionInstallerReceiver : BroadcastReceiver() {
|
||||
|
||||
// This is a cyclic dependency injection, I know but this is the best option for now
|
||||
@Inject
|
||||
lateinit var installManager: InstallManager
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
|
||||
|
||||
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
|
||||
// prompts user to enable unknown source
|
||||
val promptIntent: Intent? = intent.getParcelableExtra(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)
|
||||
|
||||
context.startActivity(it)
|
||||
}
|
||||
} else {
|
||||
notifyStatus(intent, context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyStatus(intent: Intent, context: Context) {
|
||||
val packageManager = context.packageManager
|
||||
val notificationManager = context.notificationManager
|
||||
|
||||
context.createNotificationChannel(
|
||||
id = NOTIFICATION_CHANNEL_INSTALL,
|
||||
name = context.getString(R.string.install)
|
||||
)
|
||||
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
|
||||
val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
val isUninstall = intent.getBooleanExtra(ACTION_UNINSTALL, false)
|
||||
|
||||
val appName = packageManager.getPackageName(packageName)
|
||||
|
||||
if (packageName != null) {
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
notificationManager?.removeInstallNotification(packageName)
|
||||
val notification = context.createInstallNotification(
|
||||
appName = (appName ?: packageName.substringAfterLast('.')).toString(),
|
||||
state = InstallState.Installed,
|
||||
isUninstall = isUninstall,
|
||||
) {
|
||||
setTimeoutAfter(SUCCESS_TIMEOUT)
|
||||
}
|
||||
notificationManager?.installNotification(
|
||||
packageName = packageName.toString(),
|
||||
notification = notification,
|
||||
)
|
||||
}
|
||||
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
||||
installManager.remove(packageName.toPackageName())
|
||||
}
|
||||
|
||||
else -> {
|
||||
installManager.remove(packageName.toPackageName())
|
||||
val notification = context.createInstallNotification(
|
||||
appName = appName.toString(),
|
||||
state = InstallState.Failed,
|
||||
) {
|
||||
setContentText(message)
|
||||
}
|
||||
notificationManager?.installNotification(
|
||||
packageName = packageName,
|
||||
notification = notification
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_UNINSTALL = "action_uninstall"
|
||||
|
||||
private const val SUCCESS_TIMEOUT = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.looker.droidify.installer.installers.shizuku
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.extension.size
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.installer.installers.Installer
|
||||
import com.looker.droidify.installer.installers.uninstallPackage
|
||||
import com.looker.droidify.installer.model.InstallItem
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStream
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class ShizukuInstaller(private val context: Context) : Installer {
|
||||
|
||||
companion object {
|
||||
private val SESSION_ID_REGEX = Regex("(?<=\\[).+?(?=])")
|
||||
}
|
||||
|
||||
override suspend fun install(
|
||||
installItem: InstallItem
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
var sessionId: String? = null
|
||||
val file = Cache.getReleaseFile(context, installItem.installFileName)
|
||||
val packageName = installItem.packageName.name
|
||||
try {
|
||||
val fileSize = file.size ?: run {
|
||||
cont.cancel()
|
||||
error("File is not valid: Size ${file.size}")
|
||||
}
|
||||
if (cont.isCompleted) return@suspendCancellableCoroutine
|
||||
file.inputStream().use {
|
||||
val createCommand =
|
||||
if (SdkCheck.isNougat) {
|
||||
"pm install-create --user current -i $packageName -S $fileSize"
|
||||
} else {
|
||||
"pm install-create -i $packageName -S $fileSize"
|
||||
}
|
||||
val createResult = exec(createCommand)
|
||||
sessionId = SESSION_ID_REGEX.find(createResult.out)?.value
|
||||
?: run {
|
||||
cont.cancel()
|
||||
throw RuntimeException("Failed to create install session")
|
||||
}
|
||||
if (cont.isCompleted) return@suspendCancellableCoroutine
|
||||
|
||||
val writeResult = exec("pm install-write -S $fileSize $sessionId base -", it)
|
||||
if (writeResult.resultCode != 0) {
|
||||
cont.cancel()
|
||||
throw RuntimeException("Failed to write APK to session $sessionId")
|
||||
}
|
||||
if (cont.isCompleted) return@suspendCancellableCoroutine
|
||||
|
||||
val commitResult = exec("pm install-commit $sessionId")
|
||||
if (commitResult.resultCode != 0) {
|
||||
cont.cancel()
|
||||
throw RuntimeException("Failed to commit install session $sessionId")
|
||||
}
|
||||
if (cont.isCompleted) return@suspendCancellableCoroutine
|
||||
cont.resume(InstallState.Installed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (sessionId != null) exec("pm install-abandon $sessionId")
|
||||
cont.resume(InstallState.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uninstall(packageName: PackageName) =
|
||||
context.uninstallPackage(packageName)
|
||||
|
||||
override fun close() {}
|
||||
|
||||
private data class ShellResult(val resultCode: Int, val out: String)
|
||||
|
||||
private fun exec(command: String, stdin: InputStream? = null): ShellResult {
|
||||
val process = rikka.shizuku.Shizuku.newProcess(arrayOf("sh", "-c", command), null, null)
|
||||
if (stdin != null) {
|
||||
process.outputStream.use { stdin.copyTo(it) }
|
||||
}
|
||||
val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
val resultCode = process.waitFor()
|
||||
return ShellResult(resultCode, output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.looker.droidify.installer.model
|
||||
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.domain.model.toPackageName
|
||||
|
||||
class InstallItem(
|
||||
val packageName: PackageName,
|
||||
val installFileName: String
|
||||
)
|
||||
|
||||
infix fun String.installFrom(fileName: String) = InstallItem(this.toPackageName(), fileName)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.looker.droidify.installer.model
|
||||
|
||||
enum class InstallState { Failed, Pending, Installing, Installed }
|
||||
|
||||
inline val InstallState.isCancellable: Boolean
|
||||
get() = this == InstallState.Pending
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.looker.droidify.installer.notification
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.looker.droidify.utility.common.Constants.NOTIFICATION_CHANNEL_INSTALL
|
||||
import com.looker.droidify.utility.common.Constants.NOTIFICATION_ID_INSTALL
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.R
|
||||
|
||||
fun NotificationManager.installNotification(
|
||||
packageName: String,
|
||||
notification: Notification,
|
||||
) {
|
||||
notify(
|
||||
installTag(packageName),
|
||||
NOTIFICATION_ID_INSTALL,
|
||||
notification
|
||||
)
|
||||
}
|
||||
|
||||
fun NotificationManager.removeInstallNotification(
|
||||
packageName: String,
|
||||
) {
|
||||
cancel(installTag(packageName), NOTIFICATION_ID_INSTALL)
|
||||
}
|
||||
|
||||
fun installTag(name: String): String = "install-${name.trim().replace(' ', '_')}"
|
||||
|
||||
private const val SUCCESS_TIMEOUT = 5_000L
|
||||
|
||||
fun Context.createInstallNotification(
|
||||
appName: String,
|
||||
state: InstallState,
|
||||
isUninstall: Boolean = false,
|
||||
autoCancel: Boolean = true,
|
||||
block: NotificationCompat.Builder.() -> Unit = {},
|
||||
): Notification {
|
||||
return NotificationCompat
|
||||
.Builder(this, NOTIFICATION_CHANNEL_INSTALL)
|
||||
.apply {
|
||||
setAutoCancel(autoCancel)
|
||||
setOngoing(false)
|
||||
setOnlyAlertOnce(true)
|
||||
setColor(Color.GREEN)
|
||||
val (title, text) = if (isUninstall) {
|
||||
setTimeoutAfter(SUCCESS_TIMEOUT)
|
||||
setSmallIcon(R.drawable.ic_delete)
|
||||
getString(R.string.uninstalled_application) to
|
||||
getString(R.string.uninstalled_application_DESC, appName)
|
||||
} else {
|
||||
when (state) {
|
||||
InstallState.Failed -> {
|
||||
setSmallIcon(R.drawable.ic_bug_report)
|
||||
getString(R.string.installation_failed) to
|
||||
getString(R.string.installation_failed_DESC, appName)
|
||||
}
|
||||
|
||||
InstallState.Pending -> {
|
||||
setSmallIcon(R.drawable.ic_download)
|
||||
getString(R.string.downloaded_FORMAT, appName) to
|
||||
getString(R.string.tap_to_install_DESC)
|
||||
}
|
||||
|
||||
InstallState.Installing -> {
|
||||
setSmallIcon(R.drawable.ic_download)
|
||||
setProgress(-1, -1, true)
|
||||
getString(R.string.installing) to
|
||||
appName
|
||||
}
|
||||
|
||||
InstallState.Installed -> {
|
||||
setTimeoutAfter(SUCCESS_TIMEOUT)
|
||||
setSmallIcon(R.drawable.ic_check)
|
||||
getString(R.string.installed) to
|
||||
appName
|
||||
}
|
||||
}
|
||||
}
|
||||
setContentTitle(title)
|
||||
setContentText(text)
|
||||
block()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
class InstalledItem(
|
||||
val packageName: String,
|
||||
val version: String,
|
||||
val versionCode: Long,
|
||||
val signature: String
|
||||
)
|
||||
128
app/src/main/kotlin/com/looker/droidify/model/Product.kt
Normal file
128
app/src/main/kotlin/com/looker/droidify/model/Product.kt
Normal file
@@ -0,0 +1,128 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.utility.common.extension.getColorFromAttr
|
||||
import com.looker.droidify.utility.common.extension.videoPlaceHolder
|
||||
import com.google.android.material.R as MaterialR
|
||||
|
||||
data class Product(
|
||||
var repositoryId: Long,
|
||||
val packageName: String,
|
||||
val name: String,
|
||||
val summary: String,
|
||||
var description: String,
|
||||
val whatsNew: String,
|
||||
val icon: String,
|
||||
val metadataIcon: String,
|
||||
val author: Author,
|
||||
val source: String,
|
||||
val changelog: String,
|
||||
val web: String,
|
||||
val tracker: String,
|
||||
val added: Long,
|
||||
val updated: Long,
|
||||
val suggestedVersionCode: Long,
|
||||
val categories: List<String>,
|
||||
val antiFeatures: List<String>,
|
||||
val licenses: List<String>,
|
||||
val donates: List<Donate>,
|
||||
val screenshots: List<Screenshot>,
|
||||
val releases: List<Release>
|
||||
) {
|
||||
data class Author(val name: String, val email: String, val web: String)
|
||||
|
||||
sealed class Donate {
|
||||
data class Regular(val url: String) : Donate()
|
||||
data class Bitcoin(val address: String) : Donate()
|
||||
data class Litecoin(val address: String) : Donate()
|
||||
data class Liberapay(val id: String) : Donate()
|
||||
data class OpenCollective(val id: String) : Donate()
|
||||
}
|
||||
|
||||
class Screenshot(val locale: String, val type: Type, val path: String) {
|
||||
enum class Type(val jsonName: String) {
|
||||
VIDEO("video"),
|
||||
PHONE("phone"),
|
||||
SMALL_TABLET("smallTablet"),
|
||||
LARGE_TABLET("largeTablet"),
|
||||
WEAR("wear"),
|
||||
TV("tv")
|
||||
}
|
||||
|
||||
val identifier: String
|
||||
get() = "$locale.${type.name}.$path"
|
||||
|
||||
fun url(
|
||||
context: Context,
|
||||
repository: Repository,
|
||||
packageName: String
|
||||
): Any {
|
||||
if (type == Type.VIDEO) return context.videoPlaceHolder.apply {
|
||||
setTintList(context.getColorFromAttr(MaterialR.attr.colorOnSurfaceInverse))
|
||||
}
|
||||
val phoneType = when (type) {
|
||||
Type.PHONE -> "phoneScreenshots"
|
||||
Type.SMALL_TABLET -> "sevenInchScreenshots"
|
||||
Type.LARGE_TABLET -> "tenInchScreenshots"
|
||||
Type.WEAR -> "wearScreenshots"
|
||||
Type.TV -> "tvScreenshots"
|
||||
else -> error("Should not be here, video url already returned")
|
||||
}
|
||||
return "${repository.address}/$packageName/$locale/$phoneType/$path"
|
||||
}
|
||||
}
|
||||
|
||||
// Same releases with different signatures
|
||||
val selectedReleases: List<Release>
|
||||
get() = releases.filter { it.selected }
|
||||
|
||||
val displayRelease: Release?
|
||||
get() = selectedReleases.firstOrNull() ?: releases.firstOrNull()
|
||||
|
||||
val version: String
|
||||
get() = displayRelease?.version.orEmpty()
|
||||
|
||||
val versionCode: Long
|
||||
get() = selectedReleases.firstOrNull()?.versionCode ?: 0L
|
||||
|
||||
val compatible: Boolean
|
||||
get() = selectedReleases.firstOrNull()?.incompatibilities?.isEmpty() == true
|
||||
|
||||
val signatures: List<String>
|
||||
get() = selectedReleases.mapNotNull { it.signature.ifBlank { null } }.distinct().toList()
|
||||
|
||||
fun item(): ProductItem {
|
||||
return ProductItem(
|
||||
repositoryId,
|
||||
packageName,
|
||||
name,
|
||||
summary,
|
||||
icon,
|
||||
metadataIcon,
|
||||
version,
|
||||
"",
|
||||
compatible,
|
||||
false,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
fun canUpdate(installedItem: InstalledItem?): Boolean {
|
||||
return installedItem != null && compatible && versionCode > installedItem.versionCode &&
|
||||
installedItem.signature in signatures
|
||||
}
|
||||
}
|
||||
|
||||
fun List<Pair<Product, Repository>>.findSuggested(
|
||||
installedItem: InstalledItem?
|
||||
): Pair<Product, Repository>? = maxWithOrNull(
|
||||
compareBy(
|
||||
{ (product, _) ->
|
||||
product.compatible &&
|
||||
(installedItem == null || installedItem.signature in product.signatures)
|
||||
},
|
||||
{ (product, _) ->
|
||||
product.versionCode
|
||||
}
|
||||
)
|
||||
)
|
||||
56
app/src/main/kotlin/com/looker/droidify/model/ProductItem.kt
Normal file
56
app/src/main/kotlin/com/looker/droidify/model/ProductItem.kt
Normal file
@@ -0,0 +1,56 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import com.looker.droidify.utility.common.extension.dpi
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
data class ProductItem(
|
||||
var repositoryId: Long,
|
||||
var packageName: String,
|
||||
var name: String,
|
||||
var summary: String,
|
||||
val icon: String,
|
||||
val metadataIcon: String,
|
||||
val version: String,
|
||||
var installedVersion: String,
|
||||
var compatible: Boolean,
|
||||
var canUpdate: Boolean,
|
||||
var matchRank: Int
|
||||
) {
|
||||
sealed interface Section : Parcelable {
|
||||
|
||||
@Parcelize
|
||||
object All : Section
|
||||
|
||||
@Parcelize
|
||||
class Category(val name: String) : Section
|
||||
|
||||
@Parcelize
|
||||
class Repository(val id: Long, val name: String) : Section
|
||||
}
|
||||
|
||||
private val supportedDpi = intArrayOf(120, 160, 240, 320, 480, 640)
|
||||
private var deviceDpi: Int = -1
|
||||
|
||||
fun icon(
|
||||
view: View,
|
||||
repository: Repository
|
||||
): String? {
|
||||
if (packageName.isBlank()) return null
|
||||
if (icon.isBlank() && metadataIcon.isBlank()) return null
|
||||
if (repository.version < 11 && icon.isNotBlank()) {
|
||||
return "${repository.address}/icons/$icon"
|
||||
}
|
||||
if (icon.isNotBlank()) {
|
||||
if (deviceDpi == -1) {
|
||||
deviceDpi = supportedDpi.find { it >= view.dpi } ?: supportedDpi.last()
|
||||
}
|
||||
return "${repository.address}/icons-$deviceDpi/$icon"
|
||||
}
|
||||
if (metadataIcon.isNotBlank()) {
|
||||
return "${repository.address}/$packageName/$metadataIcon"
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
data class ProductPreference(val ignoreUpdates: Boolean, val ignoreVersionCode: Long) {
|
||||
fun shouldIgnoreUpdate(versionCode: Long): Boolean {
|
||||
return ignoreUpdates || ignoreVersionCode == versionCode
|
||||
}
|
||||
}
|
||||
46
app/src/main/kotlin/com/looker/droidify/model/Release.kt
Normal file
46
app/src/main/kotlin/com/looker/droidify/model/Release.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
data class Release(
|
||||
val selected: Boolean,
|
||||
val version: String,
|
||||
val versionCode: Long,
|
||||
val added: Long,
|
||||
val size: Long,
|
||||
val minSdkVersion: Int,
|
||||
val targetSdkVersion: Int,
|
||||
val maxSdkVersion: Int,
|
||||
val source: String,
|
||||
val release: String,
|
||||
val hash: String,
|
||||
val hashType: String,
|
||||
val signature: String,
|
||||
val obbMain: String,
|
||||
val obbMainHash: String,
|
||||
val obbMainHashType: String,
|
||||
val obbPatch: String,
|
||||
val obbPatchHash: String,
|
||||
val obbPatchHashType: String,
|
||||
val permissions: List<String>,
|
||||
val features: List<String>,
|
||||
val platforms: List<String>,
|
||||
val incompatibilities: List<Incompatibility>
|
||||
) {
|
||||
sealed class Incompatibility {
|
||||
object MinSdk : Incompatibility()
|
||||
object MaxSdk : Incompatibility()
|
||||
object Platform : Incompatibility()
|
||||
class Feature(val feature: String) : Incompatibility()
|
||||
}
|
||||
|
||||
val identifier: String
|
||||
get() = "$versionCode.$hash"
|
||||
|
||||
fun getDownloadUrl(repository: Repository): String {
|
||||
return Uri.parse(repository.address).buildUpon().appendPath(release).build().toString()
|
||||
}
|
||||
|
||||
val cacheFileName: String
|
||||
get() = "${hash.replace('/', '-')}.apk"
|
||||
}
|
||||
417
app/src/main/kotlin/com/looker/droidify/model/Repository.kt
Normal file
417
app/src/main/kotlin/com/looker/droidify/model/Repository.kt
Normal file
@@ -0,0 +1,417 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
import java.net.URL
|
||||
|
||||
data class Repository(
|
||||
var id: Long,
|
||||
val address: String,
|
||||
val mirrors: List<String>,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val version: Int,
|
||||
val enabled: Boolean,
|
||||
val fingerprint: String,
|
||||
val lastModified: String,
|
||||
val entityTag: String,
|
||||
val updated: Long,
|
||||
val timestamp: Long,
|
||||
val authentication: String,
|
||||
) {
|
||||
|
||||
fun edit(address: String, fingerprint: String, authentication: String): Repository {
|
||||
val isAddressChanged = this.address != address
|
||||
val isFingerprintChanged = this.fingerprint != fingerprint
|
||||
val shouldForceUpdate = isAddressChanged || isFingerprintChanged
|
||||
return copy(
|
||||
address = address,
|
||||
fingerprint = fingerprint,
|
||||
lastModified = if (shouldForceUpdate) "" else lastModified,
|
||||
entityTag = if (shouldForceUpdate) "" else entityTag,
|
||||
authentication = authentication
|
||||
)
|
||||
}
|
||||
|
||||
fun update(
|
||||
mirrors: List<String>,
|
||||
name: String,
|
||||
description: String,
|
||||
version: Int,
|
||||
lastModified: String,
|
||||
entityTag: String,
|
||||
timestamp: Long,
|
||||
): Repository {
|
||||
return copy(
|
||||
mirrors = mirrors,
|
||||
name = name,
|
||||
description = description,
|
||||
version = if (version >= 0) version else this.version,
|
||||
lastModified = lastModified,
|
||||
entityTag = entityTag,
|
||||
updated = System.currentTimeMillis(),
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
|
||||
fun enable(enabled: Boolean): Repository {
|
||||
return copy(enabled = enabled, lastModified = "", entityTag = "")
|
||||
}
|
||||
|
||||
@Suppress("SpellCheckingInspection")
|
||||
companion object {
|
||||
|
||||
fun newRepository(
|
||||
address: String,
|
||||
fingerprint: String,
|
||||
authentication: String,
|
||||
): Repository {
|
||||
val name = try {
|
||||
URL(address).let { "${it.host}${it.path}" }
|
||||
} catch (e: Exception) {
|
||||
address
|
||||
}
|
||||
return defaultRepository(address, name, "", 0, true, fingerprint, authentication)
|
||||
}
|
||||
|
||||
private fun defaultRepository(
|
||||
address: String,
|
||||
name: String,
|
||||
description: String,
|
||||
version: Int = 21,
|
||||
enabled: Boolean = false,
|
||||
fingerprint: String,
|
||||
authentication: String = "",
|
||||
): Repository {
|
||||
return Repository(
|
||||
-1, address, emptyList(), name, description, version, enabled,
|
||||
fingerprint, "", "", 0L, 0L, authentication
|
||||
)
|
||||
}
|
||||
|
||||
val defaultRepositories = listOf(
|
||||
defaultRepository(
|
||||
address = "https://f-droid.org/repo",
|
||||
name = "F-Droid",
|
||||
description = "The official F-Droid Free Software repos" +
|
||||
"itory. Everything in this repository is always buil" +
|
||||
"t from the source code.",
|
||||
enabled = true,
|
||||
fingerprint = "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://f-droid.org/archive",
|
||||
name = "F-Droid Archive",
|
||||
description = "The archive of the official F-Droid Free" +
|
||||
" Software repository. Apps here are old and can co" +
|
||||
"ntain known vulnerabilities and security issues!",
|
||||
fingerprint = "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://guardianproject.info/fdroid/repo",
|
||||
name = "Guardian Project Official Releases",
|
||||
description = "The official repository of The Guardian " +
|
||||
"Project apps for use with the F-Droid client. Appl" +
|
||||
"ications in this repository are official binaries " +
|
||||
"built by the original application developers and " +
|
||||
"signed by the same key as the APKs that are relea" +
|
||||
"sed in the Google Play Store.",
|
||||
fingerprint = "B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://guardianproject.info/fdroid/archive",
|
||||
name = "Guardian Project Archive",
|
||||
description = "The official repository of The Guardian Pr" +
|
||||
"oject apps for use with the F-Droid client. This con" +
|
||||
"tains older versions of applications from the main repository.",
|
||||
fingerprint = "B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://apt.izzysoft.de/fdroid/repo",
|
||||
name = "IzzyOnDroid F-Droid Repo",
|
||||
description = "This is a repository of apps to be used with" +
|
||||
" F-Droid the original application developers, taken" +
|
||||
" from the resp. repositories (mostly GitHub). At thi" +
|
||||
"s moment I cannot give guarantees on regular updates" +
|
||||
" for all of them, though most are checked multiple times a week ",
|
||||
enabled = true,
|
||||
fingerprint = "3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://microg.org/fdroid/repo",
|
||||
name = "MicroG Project",
|
||||
description = "The official repository for MicroG." +
|
||||
" MicroG is a lightweight open-source implementation" +
|
||||
" of Google Play Services.",
|
||||
fingerprint = "9BD06727E62796C0130EB6DAB39B73157451582CBD138E86C468ACC395D14165"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://repo.netsyms.com/fdroid/repo",
|
||||
name = "Netsyms Technologies",
|
||||
description = "Official collection of open-source apps created" +
|
||||
" by Netsyms Technologies.",
|
||||
fingerprint = "2581BA7B32D3AB443180C4087CAB6A7E8FB258D3A6E98870ECB3C675E4D64489"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://molly.im/fdroid/foss/fdroid/repo",
|
||||
name = "Molly",
|
||||
description = "The official repository for Molly. " +
|
||||
"Molly is a fork of Signal focused on security.",
|
||||
fingerprint = "5198DAEF37FC23C14D5EE32305B2AF45787BD7DF2034DE33AD302BDB3446DF74"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://archive.newpipe.net/fdroid/repo",
|
||||
name = "NewPipe",
|
||||
description = "The official repository for NewPipe." +
|
||||
" NewPipe is a lightweight client for Youtube, PeerTube" +
|
||||
", Soundcloud, etc.",
|
||||
fingerprint = "E2402C78F9B97C6C89E97DB914A2751FDA1D02FE2039CC0897A462BDB57E7501"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://www.collaboraoffice.com/downloads/fdroid/repo",
|
||||
name = "Collabora Office",
|
||||
description = "Collabora Office is an office suite based on LibreOffice.",
|
||||
fingerprint = "573258C84E149B5F4D9299E7434B2B69A8410372921D4AE586BA91EC767892CC"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.libretro.com/repo",
|
||||
name = "LibRetro",
|
||||
description = "The official canary repository for this great" +
|
||||
" retro emulators hub.",
|
||||
fingerprint = "3F05B24D497515F31FEAB421297C79B19552C5C81186B3750B7C131EF41D733D"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://cdn.kde.org/android/fdroid/repo",
|
||||
name = "KDE Android",
|
||||
description = "The official nightly repository for KDE Android apps.",
|
||||
fingerprint = "B3EBE10AFA6C5C400379B34473E843D686C61AE6AD33F423C98AF903F056523F"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://calyxos.gitlab.io/calyx-fdroid-repo/fdroid/repo",
|
||||
name = "Calyx OS Repo",
|
||||
description = "The official Calyx Labs F-Droid repository.",
|
||||
fingerprint = "C44D58B4547DE5096138CB0B34A1CC99DAB3B4274412ED753FCCBFC11DC1B7B6"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://divestos.org/fdroid/official",
|
||||
name = "Divest OS Repo",
|
||||
description = "The official Divest OS F-Droid repository.",
|
||||
fingerprint = "E4BE8D6ABFA4D9D4FEEF03CDDA7FF62A73FD64B75566F6DD4E5E577550BE8467"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.fedilab.app/repo",
|
||||
name = "Fedilab",
|
||||
description = "The official repository for Fedilab. Fedilab is a " +
|
||||
"multi-accounts client for Mastodon, Peertube, and other free" +
|
||||
" software social networks.",
|
||||
fingerprint = "11F0A69910A4280E2CD3CCC3146337D006BE539B18E1A9FEACE15FF757A94FEB"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://store.nethunter.com/repo",
|
||||
name = "Kali Nethunter",
|
||||
description = "Kali Nethunter's official selection of original b" +
|
||||
"inaries.",
|
||||
fingerprint = "7E418D34C3AD4F3C37D7E6B0FACE13332364459C862134EB099A3BDA2CCF4494"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://secfirst.org/fdroid/repo",
|
||||
name = "Umbrella",
|
||||
description = "The official repository for Umbrella. Umbrella is" +
|
||||
" a collection of security advices, tutorials, tools etc.",
|
||||
fingerprint = "39EB57052F8D684514176819D1645F6A0A7BD943DBC31AB101949006AC0BC228"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://thecapslock.gitlab.io/fdroid-patched-apps/fdroid/repo",
|
||||
name = "Patched Apps",
|
||||
description = "A collection of patched applications to provid" +
|
||||
"e better compatibility, privacy etc..",
|
||||
fingerprint = "313D9E6E789FF4E8E2D687AAE31EEF576050003ED67963301821AC6D3763E3AC"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://mobileapp.bitwarden.com/fdroid/repo",
|
||||
name = "Bitwarden",
|
||||
description = "The official repository for Bitwarden. Bitward" +
|
||||
"en is a password manager.",
|
||||
fingerprint = "BC54EA6FD1CD5175BCCCC47C561C5726E1C3ED7E686B6DB4B18BAC843A3EFE6C"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://briarproject.org/fdroid/repo",
|
||||
name = "Briar",
|
||||
description = "The official repository for Briar. Briar is a" +
|
||||
" serverless/offline messenger that focused on privacy, s" +
|
||||
"ecurity, and decentralization.",
|
||||
fingerprint = "1FB874BEE7276D28ECB2C9B06E8A122EC4BCB4008161436CE474C257CBF49BD6"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://guardianproject-wind.s3.amazonaws.com/fdroid/repo",
|
||||
name = "Wind Project",
|
||||
description = "A collection of interesting offline/serverless apps.",
|
||||
fingerprint = "182CF464D219D340DA443C62155198E399FEC1BC4379309B775DD9FC97ED97E1"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://nanolx.org/fdroid/repo",
|
||||
name = "NanoDroid",
|
||||
description = "A companion repository to microG's installer.",
|
||||
fingerprint = "862ED9F13A3981432BF86FE93D14596B381D75BE83A1D616E2D44A12654AD015"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://releases.threema.ch/fdroid/repo",
|
||||
name = "Threema Libre",
|
||||
description = "The official repository for Threema Libre. R" +
|
||||
"equires Threema Shop license. Threema Libre is an open" +
|
||||
"-source messanger focused on security and privacy.",
|
||||
fingerprint = "5734E753899B25775D90FE85362A49866E05AC4F83C05BEF5A92880D2910639E"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.getsession.org/fdroid/repo",
|
||||
name = "Session",
|
||||
description = "The official repository for Session. Session" +
|
||||
" is an open-source messanger focused on security and privacy.",
|
||||
fingerprint = "DB0E5297EB65CC22D6BD93C869943BDCFCB6A07DC69A48A0DD8C7BA698EC04E6"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://www.cromite.org/fdroid/repo",
|
||||
name = "Cromite",
|
||||
description = "The official repository for Cromite. Cromite" +
|
||||
" is a Chromium with ad blocking and enhanced privacy.",
|
||||
fingerprint = "49F37E74DEE483DCA2B991334FB5A0200787430D0B5F9A783DD5F13695E9517B"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.twinhelix.com/fdroid/repo",
|
||||
name = "TwinHelix",
|
||||
description = "TwinHelix F-Droid Repository, used for Signa" +
|
||||
"l-FOSS, an open-source fork of Signal Private Messenger.",
|
||||
fingerprint = "7b03b0232209b21b10a30a63897d3c6bca4f58fe29bc3477e8e3d8cf8e304028"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.typeblog.net",
|
||||
name = "PeterCxy's F-Droid",
|
||||
description = "You have landed on PeterCxy's F-Droid repo. T" +
|
||||
"o use this repository, please add the page's URL to your F-Droid client.",
|
||||
fingerprint = "1a7e446c491c80bc2f83844a26387887990f97f2f379ae7b109679feae3dbc8c"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://s2.spiritcroc.de/fdroid/repo",
|
||||
name = "SpiritCroc.de",
|
||||
description = "While some of my apps are available from" +
|
||||
" the official F-Droid repository, I also maintain my" +
|
||||
" own repository for a small selection of apps. These" +
|
||||
" might be forks of other apps with only minor change" +
|
||||
"s, or apps that are not published on the Play Store f" +
|
||||
"or other reasons. In contrast to the official F-Droid" +
|
||||
" repos, these might also include proprietary librarie" +
|
||||
"s, e.g. for push notifications.",
|
||||
fingerprint = "6612ade7e93174a589cf5ba26ed3ab28231a789640546c8f30375ef045bc9242"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://s2.spiritcroc.de/testing/fdroid/repo",
|
||||
name = "SpiritCroc.de Test Builds",
|
||||
description = "SpiritCroc.de Test Builds",
|
||||
fingerprint = "52d03f2fab785573bb295c7ab270695e3a1bdd2adc6a6de8713250b33f231225"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://static.cryptomator.org/android/fdroid/repo",
|
||||
name = "Cryptomator",
|
||||
description = "No Description",
|
||||
fingerprint = "f7c3ec3b0d588d3cb52983e9eb1a7421c93d4339a286398e71d7b651e8d8ecdd"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://divestos.org/apks/unofficial/fdroid/repo",
|
||||
name = "DivestOS Unofficial",
|
||||
description = "This repository contains unofficial builds of open source apps" +
|
||||
" that are not included in the other repos.",
|
||||
fingerprint = "a18cdb92f40ebfbbf778a54fd12dbd74d90f1490cb9ef2cc6c7e682dd556855d"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://cdn.kde.org/android/stable-releases/fdroid/repo",
|
||||
name = "KDE Stables",
|
||||
description = "This repository contains unofficial builds of open source apps" +
|
||||
" that are not included in the other repos.",
|
||||
fingerprint = "13784ba6c80ff4e2181e55c56f961eed5844cea16870d3b38d58780b85e1158f"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://zimbelstern.eu/fdroid/repo",
|
||||
name = "Zimbelstern's F-Droid repository",
|
||||
description = "This is the official repository of apps from zimbelstern.eu," +
|
||||
" to be used with F-Droid.",
|
||||
fingerprint = "285158DECEF37CB8DE7C5AF14818ACBF4A9B1FBE63116758EFC267F971CA23AA"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://app.simplex.chat/fdroid/repo",
|
||||
name = "SimpleX Chat F-Droid",
|
||||
description = "SimpleX Chat official F-Droid repository.",
|
||||
fingerprint = "9F358FF284D1F71656A2BFAF0E005DEAE6AA14143720E089F11FF2DDCFEB01BA"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://f-droid.monerujo.io/fdroid/repo",
|
||||
name = "Monerujo Wallet",
|
||||
description = "Monerujo Monero Wallet official F-Droid repository.",
|
||||
fingerprint = "A82C68E14AF0AA6A2EC20E6B272EFF25E5A038F3F65884316E0F5E0D91E7B713"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.cakelabs.com/fdroid/repo",
|
||||
name = "Cake Labs",
|
||||
description = "Cake Labs official F-Droid repository for Cake Wallet and Monero.com",
|
||||
fingerprint = "EA44EFAEE0B641EE7A032D397D5D976F9C4E5E1ED26E11C75702D064E55F8755"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://app.futo.org/fdroid/repo",
|
||||
name = "FUTO",
|
||||
description = "FUTO official F-Droid repository.",
|
||||
fingerprint = "39D47869D29CBFCE4691D9F7E6946A7B6D7E6FF4883497E6E675744ECDFA6D6D"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.mm20.de/repo",
|
||||
name = "MM20 Apps",
|
||||
description = "Apps developed and distributed by MM20",
|
||||
fingerprint = "156FBAB952F6996415F198F3F29628D24B30E725B0F07A2B49C3A9B5161EEE1A"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://breezy-weather.github.io/fdroid-repo/fdroid/repo",
|
||||
name = "Breezy Weather",
|
||||
description = "The F-Droid repository for Breezy Weather",
|
||||
fingerprint = "3480A7BB2A296D8F98CB90D2309199B5B9519C1B31978DBCD877ADB102AF35EE"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://gh.artemchep.com/keyguard-repo-fdroid/repo",
|
||||
name = "Keyguard Project",
|
||||
description = "Mirrors artifacts available on https://github.com/AChep/keyguard-app/releases",
|
||||
fingerprint = "03941CE79B081666609C8A48AB6E46774263F6FC0BBF1FA046CCFFC60EA643BC"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://f5a.torus.icu/fdroid/repo",
|
||||
name = "Fcitx 5 For Android F-Droid Repo",
|
||||
description = "Out-of-tree fcitx5-android plugins.",
|
||||
fingerprint = "5D87CE1FAD3772425C2A7ED987A57595A20B07543B9595A7FD2CED25DFF3CF12"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.i2pd.xyz/fdroid/repo/",
|
||||
name = "PurpleI2P F-Droid repository",
|
||||
description = "This is a repository of PurpleI2P. It contains applications developed and supported by our team.",
|
||||
fingerprint = "5D87CE1FAD3772425C2A7ED987A57595A20B07543B9595A7FD2CED25DFF3CF12"
|
||||
),
|
||||
)
|
||||
|
||||
val newlyAdded: List<Repository> = listOf(
|
||||
defaultRepository(
|
||||
address = "https://fdroid.ironfoxoss.org/fdroid/repo",
|
||||
name = "IronFox",
|
||||
description = "The official repository for IronFox:" +
|
||||
" A privacy and security-oriented Firefox-based browser for Android.",
|
||||
fingerprint = "C5E291B5A571F9C8CD9A9799C2C94E02EC9703948893F2CA756D67B94204F904"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://raw.githubusercontent.com/chrisgch/tca/master/fdroid/repo",
|
||||
name = "Total Commander",
|
||||
description = "The official repository for Total Commander",
|
||||
fingerprint = "3576596CECDD70488D61CFD90799A49B7FFD26A81A8FEF1BADEC88D069FA72C1"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://www.cromite.org/fdroid/repo",
|
||||
name = "Cromite",
|
||||
description = "The official repository for Cromite. " +
|
||||
"Cromite is a Chromium fork based on Bromite with " +
|
||||
"built-in support for ad blocking and an eye for privacy.",
|
||||
fingerprint = "49F37E74DEE483DCA2B991334FB5A0200787430D0B5F9A783DD5F13695E9517B"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
30
app/src/main/kotlin/com/looker/droidify/network/DataSize.kt
Normal file
30
app/src/main/kotlin/com/looker/droidify/network/DataSize.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.looker.droidify.network
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
@JvmInline
|
||||
value class DataSize(val value: Long) {
|
||||
|
||||
companion object {
|
||||
private const val BYTE_SIZE = 1024L
|
||||
private val sizeFormats = listOf("%.0f B", "%.0f kB", "%.1f MB", "%.2f GB")
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
val (size, index) = generateSequence(Pair(value.toFloat(), 0)) { (size, index) ->
|
||||
if (size >= BYTE_SIZE) {
|
||||
Pair(size / BYTE_SIZE, index + 1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.take(sizeFormats.size).last()
|
||||
return sizeFormats[index].format(Locale.US, size)
|
||||
}
|
||||
}
|
||||
|
||||
infix fun DataSize.percentBy(denominator: DataSize?): Int = value percentBy denominator?.value
|
||||
|
||||
infix fun Long.percentBy(denominator: Long?): Int {
|
||||
if (denominator == null || denominator < 1) return -1
|
||||
return (this * 100 / denominator).toInt()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.looker.droidify.network
|
||||
|
||||
import com.looker.droidify.network.header.HeadersBuilder
|
||||
import com.looker.droidify.network.validation.FileValidator
|
||||
import java.io.File
|
||||
import java.net.Proxy
|
||||
|
||||
interface Downloader {
|
||||
|
||||
fun setProxy(proxy: Proxy)
|
||||
|
||||
suspend fun headCall(
|
||||
url: String,
|
||||
headers: HeadersBuilder.() -> Unit = {}
|
||||
): NetworkResponse
|
||||
|
||||
suspend fun downloadToFile(
|
||||
url: String,
|
||||
target: File,
|
||||
validator: FileValidator? = null,
|
||||
headers: HeadersBuilder.() -> Unit = {},
|
||||
block: ProgressListener? = null
|
||||
): NetworkResponse
|
||||
}
|
||||
|
||||
typealias ProgressListener = suspend (bytesReceived: DataSize, contentLength: DataSize) -> Unit
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.looker.droidify.network
|
||||
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.network.header.HeadersBuilder
|
||||
import com.looker.droidify.network.header.KtorHeadersBuilder
|
||||
import com.looker.droidify.network.validation.FileValidator
|
||||
import com.looker.droidify.network.validation.ValidationException
|
||||
import com.looker.droidify.utility.common.extension.size
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.engine.HttpClientEngine
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.network.sockets.ConnectTimeoutException
|
||||
import io.ktor.client.network.sockets.SocketTimeoutException
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.UserAgent
|
||||
import io.ktor.client.plugins.onDownload
|
||||
import io.ktor.client.request.head
|
||||
import io.ktor.client.request.headers
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.request
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.etag
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.http.lastModified
|
||||
import io.ktor.utils.io.jvm.javaio.copyTo
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.Proxy
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
internal class KtorDownloader(
|
||||
httpClientEngine: HttpClientEngine,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
) : Downloader {
|
||||
|
||||
private var client = client(httpClientEngine)
|
||||
set(newClient) {
|
||||
field.close()
|
||||
field = newClient
|
||||
}
|
||||
|
||||
override fun setProxy(proxy: Proxy) {
|
||||
client = client(OkHttp.create { this.proxy = proxy })
|
||||
}
|
||||
|
||||
override suspend fun headCall(
|
||||
url: String,
|
||||
headers: HeadersBuilder.() -> Unit
|
||||
): NetworkResponse {
|
||||
val headRequest = createRequest(
|
||||
url = url,
|
||||
headers = headers
|
||||
)
|
||||
return client.head(headRequest).asNetworkResponse()
|
||||
}
|
||||
|
||||
override suspend fun downloadToFile(
|
||||
url: String,
|
||||
target: File,
|
||||
validator: FileValidator?,
|
||||
headers: HeadersBuilder.() -> Unit,
|
||||
block: ProgressListener?
|
||||
): NetworkResponse = withContext(dispatcher) {
|
||||
try {
|
||||
val request = createRequest(
|
||||
url = url,
|
||||
headers = {
|
||||
inRange(target.size)
|
||||
headers()
|
||||
},
|
||||
fileSize = target.size,
|
||||
block = block
|
||||
)
|
||||
client.prepareGet(request).execute { response ->
|
||||
val networkResponse = response.asNetworkResponse()
|
||||
if (networkResponse !is NetworkResponse.Success) {
|
||||
return@execute networkResponse
|
||||
}
|
||||
response.bodyAsChannel().copyTo(target.outputStream())
|
||||
validator?.validate(target)
|
||||
networkResponse
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
NetworkResponse.Error.SocketTimeout(e)
|
||||
} catch (e: ConnectTimeoutException) {
|
||||
NetworkResponse.Error.ConnectionTimeout(e)
|
||||
} catch (e: IOException) {
|
||||
NetworkResponse.Error.IO(e)
|
||||
} catch (e: ValidationException) {
|
||||
target.delete()
|
||||
NetworkResponse.Error.Validation(e)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
NetworkResponse.Error.Unknown(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun client(
|
||||
engine: HttpClientEngine = OkHttp.create()
|
||||
): HttpClient {
|
||||
return HttpClient(engine) {
|
||||
userAgentConfig()
|
||||
timeoutConfig()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun createRequest(
|
||||
url: String,
|
||||
headers: HeadersBuilder.() -> Unit,
|
||||
fileSize: Long? = null,
|
||||
block: ProgressListener? = null
|
||||
) = request {
|
||||
url(url)
|
||||
this.headers {
|
||||
KtorHeadersBuilder(this).headers()
|
||||
}
|
||||
onDownload { read, total ->
|
||||
if (block != null) {
|
||||
block(
|
||||
DataSize(read + (fileSize ?: 0L)),
|
||||
DataSize((total ?: 0L) + (fileSize ?: 0L))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val CONNECTION_TIMEOUT = 30_000L
|
||||
private const val SOCKET_TIMEOUT = 15_000L
|
||||
private const val USER_AGENT = "Droid-ify/${BuildConfig.VERSION_NAME}-${BuildConfig.BUILD_TYPE}"
|
||||
|
||||
private fun HttpClientConfig<*>.userAgentConfig() = install(UserAgent) {
|
||||
agent = USER_AGENT
|
||||
}
|
||||
|
||||
private fun HttpClientConfig<*>.timeoutConfig() = install(HttpTimeout) {
|
||||
connectTimeoutMillis = CONNECTION_TIMEOUT
|
||||
socketTimeoutMillis = SOCKET_TIMEOUT
|
||||
}
|
||||
|
||||
private fun HttpResponse.asNetworkResponse(): NetworkResponse =
|
||||
if (status.isSuccess() || status == HttpStatusCode.NotModified) {
|
||||
NetworkResponse.Success(status.value, lastModified(), etag())
|
||||
} else {
|
||||
NetworkResponse.Error.Http(status.value)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.looker.droidify.network
|
||||
|
||||
import com.looker.droidify.network.validation.ValidationException
|
||||
import java.util.Date
|
||||
|
||||
sealed interface NetworkResponse {
|
||||
|
||||
sealed interface Error : NetworkResponse {
|
||||
|
||||
data class ConnectionTimeout(val exception: Exception) : Error
|
||||
|
||||
data class SocketTimeout(val exception: Exception) : Error
|
||||
|
||||
data class IO(val exception: Exception) : Error
|
||||
|
||||
data class Validation(val exception: ValidationException) : Error
|
||||
|
||||
data class Unknown(val exception: Exception) : Error
|
||||
|
||||
data class Http(val statusCode: Int) : Error
|
||||
}
|
||||
|
||||
data class Success(
|
||||
val statusCode: Int,
|
||||
val lastModified: Date?,
|
||||
val etag: String?
|
||||
) : NetworkResponse
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.looker.droidify.network.header
|
||||
|
||||
import java.util.Date
|
||||
|
||||
interface HeadersBuilder {
|
||||
|
||||
infix fun String.headsWith(value: Any?)
|
||||
|
||||
fun etag(etagString: String)
|
||||
|
||||
fun ifModifiedSince(date: Date)
|
||||
|
||||
fun ifModifiedSince(date: String)
|
||||
|
||||
fun authentication(username: String, password: String)
|
||||
|
||||
fun authentication(base64: String)
|
||||
|
||||
fun inRange(start: Number?, end: Number? = null)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.looker.droidify.network.header
|
||||
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.util.encodeBase64
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
internal class KtorHeadersBuilder(
|
||||
private val builder: io.ktor.http.HeadersBuilder
|
||||
) : HeadersBuilder {
|
||||
|
||||
override fun String.headsWith(value: Any?) {
|
||||
if (value == null) return
|
||||
with(builder) {
|
||||
append(this@headsWith, value.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun etag(etagString: String) {
|
||||
HttpHeaders.ETag headsWith etagString
|
||||
}
|
||||
|
||||
override fun ifModifiedSince(date: Date) {
|
||||
HttpHeaders.IfModifiedSince headsWith date.toFormattedString()
|
||||
}
|
||||
|
||||
override fun ifModifiedSince(date: String) {
|
||||
HttpHeaders.IfModifiedSince headsWith date
|
||||
}
|
||||
|
||||
override fun authentication(username: String, password: String) {
|
||||
HttpHeaders.Authorization headsWith "Basic ${"$username:$password".encodeBase64()}"
|
||||
}
|
||||
|
||||
override fun authentication(base64: String) {
|
||||
HttpHeaders.Authorization headsWith base64
|
||||
}
|
||||
|
||||
override fun inRange(start: Number?, end: Number?) {
|
||||
if (start == null) return
|
||||
val valueString = if (end != null) "bytes=$start-$end" else "bytes=$start-"
|
||||
HttpHeaders.Range headsWith valueString
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val HTTP_DATE_FORMAT: SimpleDateFormat
|
||||
get() = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("GMT")
|
||||
}
|
||||
|
||||
fun Date.toFormattedString(): String = HTTP_DATE_FORMAT.format(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.looker.droidify.network.validation
|
||||
|
||||
import java.io.File
|
||||
|
||||
interface FileValidator {
|
||||
|
||||
@Throws(ValidationException::class)
|
||||
suspend fun validate(file: File)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.looker.droidify.network.validation
|
||||
|
||||
class ValidationException(override val message: String) : Exception(message)
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun invalid(message: String): Nothing = throw ValidationException(message)
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.looker.droidify.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import com.looker.droidify.utility.common.extension.getPackageInfoCompat
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.utility.extension.toInstalledItem
|
||||
|
||||
class InstalledAppReceiver(private val packageManager: PackageManager) : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val packageName =
|
||||
intent.data?.let { if (it.scheme == "package") it.schemeSpecificPart else null }
|
||||
if (packageName != null) {
|
||||
when (intent.action.orEmpty()) {
|
||||
Intent.ACTION_PACKAGE_ADDED,
|
||||
Intent.ACTION_PACKAGE_REMOVED
|
||||
-> {
|
||||
val packageInfo = packageManager.getPackageInfoCompat(packageName)
|
||||
if (packageInfo != null) {
|
||||
Database.InstalledAdapter.put(packageInfo.toInstalledItem())
|
||||
} else {
|
||||
Database.InstalledAdapter.delete(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.looker.droidify.service
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
|
||||
class Connection<B : IBinder, S : ConnectionService<B>>(
|
||||
private val serviceClass: Class<S>,
|
||||
private val onBind: ((Connection<B, S>, B) -> Unit)? = null,
|
||||
private val onUnbind: ((Connection<B, S>, B) -> Unit)? = null
|
||||
) : ServiceConnection {
|
||||
var binder: B? = null
|
||||
private set
|
||||
|
||||
private fun handleUnbind() {
|
||||
binder?.let {
|
||||
binder = null
|
||||
onUnbind?.invoke(this, it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
binder as B
|
||||
this.binder = binder
|
||||
onBind?.invoke(this, binder)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(componentName: ComponentName) {
|
||||
handleUnbind()
|
||||
}
|
||||
|
||||
fun bind(context: Context) {
|
||||
context.bindService(Intent(context, serviceClass), this, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
fun unbind(context: Context) {
|
||||
context.unbindService(this)
|
||||
handleUnbind()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.looker.droidify.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
|
||||
abstract class ConnectionService<T : IBinder> : Service() {
|
||||
|
||||
private val supervisorJob = SupervisorJob()
|
||||
val lifecycleScope = CoroutineScope(Dispatchers.Main + supervisorJob)
|
||||
|
||||
abstract override fun onBind(intent: Intent): T
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
lifecycleScope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package com.looker.droidify.service
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.MainActivity
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.get
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.installer.InstallManager
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.installer.model.installFrom
|
||||
import com.looker.droidify.installer.notification.createInstallNotification
|
||||
import com.looker.droidify.installer.notification.installNotification
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.network.DataSize
|
||||
import com.looker.droidify.network.Downloader
|
||||
import com.looker.droidify.network.NetworkResponse
|
||||
import com.looker.droidify.network.percentBy
|
||||
import com.looker.droidify.network.validation.ValidationException
|
||||
import com.looker.droidify.utility.common.Constants
|
||||
import com.looker.droidify.utility.common.Constants.NOTIFICATION_CHANNEL_INSTALL
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.createNotificationChannel
|
||||
import com.looker.droidify.utility.common.extension.notificationManager
|
||||
import com.looker.droidify.utility.common.extension.startServiceCompat
|
||||
import com.looker.droidify.utility.common.extension.stopForegroundCompat
|
||||
import com.looker.droidify.utility.common.extension.toPendingIntent
|
||||
import com.looker.droidify.utility.common.extension.updateAsMutable
|
||||
import com.looker.droidify.utility.common.log
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.yield
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import com.looker.droidify.R.string as stringRes
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DownloadService : ConnectionService<DownloadService.Binder>() {
|
||||
companion object {
|
||||
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var downloader: Downloader
|
||||
|
||||
private val installerType
|
||||
get() = settingsRepository.get { installerType }
|
||||
|
||||
@Inject
|
||||
lateinit var installer: InstallManager
|
||||
|
||||
sealed class State(val packageName: String) {
|
||||
data object Idle : State("")
|
||||
data class Connecting(val name: String) : State(name)
|
||||
data class Downloading(val name: String, val read: DataSize, val total: DataSize?) : State(
|
||||
name
|
||||
)
|
||||
|
||||
data class Error(val name: String) : State(name)
|
||||
data class Cancel(val name: String) : State(name)
|
||||
data class Success(val name: String, val release: Release) : State(name)
|
||||
}
|
||||
|
||||
data class DownloadState(
|
||||
val currentItem: State = State.Idle,
|
||||
val queue: List<String> = emptyList()
|
||||
) {
|
||||
infix fun isDownloading(packageName: String): Boolean =
|
||||
currentItem.packageName == packageName && (
|
||||
currentItem is State.Connecting || currentItem is State.Downloading
|
||||
)
|
||||
|
||||
infix fun isComplete(packageName: String): Boolean =
|
||||
currentItem.packageName == packageName && (
|
||||
currentItem is State.Error ||
|
||||
currentItem is State.Cancel ||
|
||||
currentItem is State.Success ||
|
||||
currentItem is State.Idle
|
||||
)
|
||||
}
|
||||
|
||||
private val _downloadState = MutableStateFlow(DownloadState())
|
||||
|
||||
private class Task(
|
||||
val packageName: String,
|
||||
val name: String,
|
||||
val release: Release,
|
||||
val url: String,
|
||||
val authentication: String,
|
||||
val isUpdate: Boolean = false
|
||||
) {
|
||||
val notificationTag: String
|
||||
get() = "download-$packageName"
|
||||
}
|
||||
|
||||
private data class CurrentTask(val task: Task, val job: Job, val lastState: State)
|
||||
|
||||
private var started = false
|
||||
private val tasks = mutableListOf<Task>()
|
||||
private var currentTask: CurrentTask? = null
|
||||
|
||||
private val lock = Mutex()
|
||||
|
||||
inner class Binder : android.os.Binder() {
|
||||
val downloadState = _downloadState.asStateFlow()
|
||||
fun enqueue(
|
||||
packageName: String,
|
||||
name: String,
|
||||
repository: Repository,
|
||||
release: Release,
|
||||
isUpdate: Boolean = false
|
||||
) {
|
||||
val task = Task(
|
||||
packageName = packageName,
|
||||
name = name,
|
||||
release = release,
|
||||
url = release.getDownloadUrl(repository),
|
||||
authentication = repository.authentication,
|
||||
isUpdate = isUpdate
|
||||
)
|
||||
if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) {
|
||||
lifecycleScope.launch { publishSuccess(task) }
|
||||
return
|
||||
}
|
||||
cancelTasks(packageName)
|
||||
cancelCurrentTask(packageName)
|
||||
notificationManager?.cancel(
|
||||
task.notificationTag,
|
||||
Constants.NOTIFICATION_ID_DOWNLOADING
|
||||
)
|
||||
tasks += task
|
||||
if (currentTask == null) {
|
||||
handleDownload()
|
||||
} else {
|
||||
updateCurrentQueue { add(packageName) }
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(packageName: String) {
|
||||
cancelTasks(packageName)
|
||||
cancelCurrentTask(packageName)
|
||||
}
|
||||
}
|
||||
|
||||
private val binder = Binder()
|
||||
override fun onBind(intent: Intent): Binder = binder
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel(
|
||||
id = Constants.NOTIFICATION_CHANNEL_DOWNLOADING,
|
||||
name = getString(stringRes.downloading),
|
||||
)
|
||||
createNotificationChannel(
|
||||
id = NOTIFICATION_CHANNEL_INSTALL,
|
||||
name = getString(stringRes.install)
|
||||
)
|
||||
|
||||
lifecycleScope.launch {
|
||||
_downloadState
|
||||
.filter { currentTask != null }
|
||||
.sample(400)
|
||||
.collectLatest {
|
||||
publishForegroundState(false, it.currentItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTimeout(startId: Int) {
|
||||
super.onTimeout(startId)
|
||||
onDestroy()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
cancelTasks(null)
|
||||
cancelCurrentTask(null)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_CANCEL) {
|
||||
currentTask?.let { binder.cancel(it.task.packageName) }
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun cancelTasks(packageName: String?) {
|
||||
tasks.removeAll {
|
||||
(packageName == null || it.packageName == packageName) && run {
|
||||
updateCurrentState(State.Cancel(it.packageName))
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelCurrentTask(packageName: String?) {
|
||||
currentTask?.let {
|
||||
if (packageName == null || it.task.packageName == packageName) {
|
||||
it.job.cancel()
|
||||
currentTask = null
|
||||
updateCurrentState(State.Cancel(it.task.packageName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface ErrorType {
|
||||
data object IO : ErrorType
|
||||
data object Http : ErrorType
|
||||
data object SocketTimeout : ErrorType
|
||||
data object ConnectionTimeout : ErrorType
|
||||
class Validation(val exception: ValidationException) : ErrorType
|
||||
}
|
||||
|
||||
private fun showNotificationError(task: Task, errorType: ErrorType) {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
.setAction(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse("package:${task.packageName}"))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
.toPendingIntent(this)
|
||||
notificationManager?.notify(
|
||||
task.notificationTag,
|
||||
Constants.NOTIFICATION_ID_DOWNLOADING,
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_DOWNLOADING)
|
||||
.setAutoCancel(true)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setColor(Color.GREEN)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(intent)
|
||||
.errorNotificationContent(task, errorType)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun NotificationCompat.Builder.errorNotificationContent(
|
||||
task: Task,
|
||||
errorType: ErrorType
|
||||
): NotificationCompat.Builder {
|
||||
val title = if (errorType is ErrorType.Validation) {
|
||||
stringRes.could_not_validate_FORMAT
|
||||
} else {
|
||||
stringRes.could_not_download_FORMAT
|
||||
}
|
||||
val description = when (errorType) {
|
||||
ErrorType.ConnectionTimeout -> getString(stringRes.connection_error_DESC)
|
||||
ErrorType.Http -> getString(stringRes.http_error_DESC)
|
||||
ErrorType.IO -> getString(stringRes.io_error_DESC)
|
||||
ErrorType.SocketTimeout -> getString(stringRes.socket_error_DESC)
|
||||
is ErrorType.Validation -> errorType.exception.message
|
||||
}
|
||||
setContentTitle(getString(title, task.name))
|
||||
return setContentText(description)
|
||||
}
|
||||
|
||||
private fun showNotificationInstall(task: Task) {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
.setAction(MainActivity.ACTION_INSTALL)
|
||||
.setData(Uri.parse("package:${task.packageName}"))
|
||||
.putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, task.release.cacheFileName)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
.toPendingIntent(this)
|
||||
val notification = createInstallNotification(
|
||||
appName = task.name,
|
||||
state = InstallState.Pending,
|
||||
autoCancel = true,
|
||||
) {
|
||||
setContentIntent(intent)
|
||||
}
|
||||
notificationManager?.installNotification(
|
||||
packageName = task.packageName,
|
||||
notification = notification,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun publishSuccess(task: Task) {
|
||||
val currentInstaller = installerType.first()
|
||||
updateCurrentQueue { add("") }
|
||||
updateCurrentState(State.Success(task.packageName, task.release))
|
||||
val autoInstallWithSessionInstaller =
|
||||
SdkCheck.canAutoInstall(task.release.targetSdkVersion) &&
|
||||
currentInstaller == InstallerType.SESSION &&
|
||||
task.isUpdate
|
||||
|
||||
showNotificationInstall(task)
|
||||
if (currentInstaller == InstallerType.ROOT ||
|
||||
currentInstaller == InstallerType.SHIZUKU ||
|
||||
autoInstallWithSessionInstaller
|
||||
) {
|
||||
val installItem = task.packageName installFrom task.release.cacheFileName
|
||||
installer install installItem
|
||||
}
|
||||
}
|
||||
|
||||
private val stateNotificationBuilder by lazy {
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_DOWNLOADING)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setColor(Color.GREEN)
|
||||
.addAction(
|
||||
0,
|
||||
getString(stringRes.cancel),
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
0,
|
||||
Intent(this, this::class.java).setAction(ACTION_CANCEL),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun publishForegroundState(force: Boolean, state: State) {
|
||||
if (!force && currentTask == null) return
|
||||
currentTask = currentTask!!.copy(lastState = state)
|
||||
stateNotificationBuilder.downloadingNotificationContent(state)
|
||||
?.let { notification ->
|
||||
startForeground(
|
||||
Constants.NOTIFICATION_ID_DOWNLOADING,
|
||||
notification.build()
|
||||
)
|
||||
} ?: run {
|
||||
log("Invalid Download State: $state", "DownloadService", Log.ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationCompat.Builder.downloadingNotificationContent(
|
||||
state: State
|
||||
): NotificationCompat.Builder? {
|
||||
return when (state) {
|
||||
is State.Connecting -> {
|
||||
setContentTitle(getString(stringRes.downloading_FORMAT, currentTask!!.task.name))
|
||||
setContentText(getString(stringRes.connecting))
|
||||
setProgress(1, 0, true)
|
||||
}
|
||||
|
||||
is State.Downloading -> {
|
||||
setContentTitle(getString(stringRes.downloading_FORMAT, currentTask!!.task.name))
|
||||
if (state.total != null) {
|
||||
setContentText("${state.read} / ${state.total}")
|
||||
setProgress(100, state.read.value percentBy state.total.value, false)
|
||||
} else {
|
||||
setContentText(state.read.toString())
|
||||
setProgress(0, 0, true)
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDownload() {
|
||||
if (currentTask != null) return
|
||||
if (tasks.isEmpty() && started) {
|
||||
started = false
|
||||
stopForegroundCompat()
|
||||
return
|
||||
}
|
||||
if (!started) {
|
||||
started = true
|
||||
startServiceCompat()
|
||||
}
|
||||
val task = tasks.removeFirstOrNull() ?: return
|
||||
with(stateNotificationBuilder) {
|
||||
setWhen(System.currentTimeMillis())
|
||||
setContentIntent(createNotificationIntent(task.packageName))
|
||||
}
|
||||
val connectionState = State.Connecting(task.packageName)
|
||||
val partialReleaseFile =
|
||||
Cache.getPartialReleaseFile(this, task.release.cacheFileName)
|
||||
val job = lifecycleScope.downloadFile(task, partialReleaseFile)
|
||||
currentTask = CurrentTask(task, job, connectionState)
|
||||
publishForegroundState(true, connectionState)
|
||||
updateCurrentState(State.Connecting(task.packageName))
|
||||
}
|
||||
|
||||
private fun createNotificationIntent(packageName: String): PendingIntent? =
|
||||
Intent(this, MainActivity::class.java)
|
||||
.setAction(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse("package:$packageName"))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.toPendingIntent(this)
|
||||
|
||||
private fun CoroutineScope.downloadFile(
|
||||
task: Task,
|
||||
target: File
|
||||
) = launch {
|
||||
try {
|
||||
val releaseValidator = ReleaseFileValidator(
|
||||
context = this@DownloadService,
|
||||
packageName = task.packageName,
|
||||
release = task.release
|
||||
)
|
||||
val response = downloader.downloadToFile(
|
||||
url = task.url,
|
||||
target = target,
|
||||
validator = releaseValidator,
|
||||
headers = { authentication(task.authentication) }
|
||||
) { read, total ->
|
||||
yield()
|
||||
updateCurrentState(State.Downloading(task.packageName, read, total))
|
||||
}
|
||||
|
||||
when (response) {
|
||||
is NetworkResponse.Success -> {
|
||||
val releaseFile = Cache.getReleaseFile(
|
||||
this@DownloadService,
|
||||
task.release.cacheFileName
|
||||
)
|
||||
target.renameTo(releaseFile)
|
||||
publishSuccess(task)
|
||||
}
|
||||
|
||||
is NetworkResponse.Error -> {
|
||||
updateCurrentState(State.Error(task.packageName))
|
||||
val errorType = when (response) {
|
||||
is NetworkResponse.Error.ConnectionTimeout -> ErrorType.ConnectionTimeout
|
||||
is NetworkResponse.Error.IO -> ErrorType.IO
|
||||
is NetworkResponse.Error.SocketTimeout -> ErrorType.SocketTimeout
|
||||
is NetworkResponse.Error.Validation -> ErrorType.Validation(
|
||||
response.exception
|
||||
)
|
||||
|
||||
else -> ErrorType.Http
|
||||
}
|
||||
showNotificationError(task, errorType)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
lock.withLock { currentTask = null }
|
||||
handleDownload()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCurrentState(state: State) {
|
||||
_downloadState.update {
|
||||
val newQueue =
|
||||
if (state.packageName in it.queue) {
|
||||
it.queue.updateAsMutable {
|
||||
removeAll { name -> name == "" }
|
||||
remove(state.packageName)
|
||||
}
|
||||
} else {
|
||||
it.queue
|
||||
}
|
||||
it.copy(currentItem = state, queue = newQueue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCurrentQueue(block: MutableList<String>.() -> Unit) {
|
||||
_downloadState.update { state ->
|
||||
state.copy(queue = state.queue.updateAsMutable(block))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.looker.droidify.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import com.looker.droidify.utility.common.extension.calculateHash
|
||||
import com.looker.droidify.utility.common.extension.getPackageArchiveInfoCompat
|
||||
import com.looker.droidify.utility.common.extension.singleSignature
|
||||
import com.looker.droidify.utility.common.extension.versionCodeCompat
|
||||
import com.looker.droidify.network.validation.FileValidator
|
||||
import com.looker.droidify.utility.common.signature.Hash
|
||||
import com.looker.droidify.network.validation.invalid
|
||||
import com.looker.droidify.utility.common.signature.verifyHash
|
||||
import com.looker.droidify.model.Release
|
||||
import java.io.File
|
||||
import com.looker.droidify.R.string as strings
|
||||
|
||||
class ReleaseFileValidator(
|
||||
private val context: Context,
|
||||
private val packageName: String,
|
||||
private val release: Release
|
||||
) : FileValidator {
|
||||
|
||||
override suspend fun validate(file: File) {
|
||||
val hash = Hash(release.hashType, release.hash)
|
||||
if (!file.verifyHash(hash)) {
|
||||
invalid(getString(strings.integrity_check_error_DESC))
|
||||
}
|
||||
val packageInfo = context.packageManager.getPackageArchiveInfoCompat(file.path)
|
||||
?: invalid(getString(strings.file_format_error_DESC))
|
||||
if (packageInfo.packageName != packageName ||
|
||||
packageInfo.versionCodeCompat != release.versionCode
|
||||
) {
|
||||
invalid(getString(strings.invalid_metadata_error_DESC))
|
||||
}
|
||||
|
||||
packageInfo.singleSignature
|
||||
?.calculateHash()
|
||||
?.takeIf { it.isNotBlank() || it == release.signature }
|
||||
?: invalid(getString(strings.invalid_signature_error_DESC))
|
||||
|
||||
packageInfo.permissions
|
||||
?.asSequence()
|
||||
.orEmpty()
|
||||
.map { it.name }
|
||||
.toSet()
|
||||
.takeIf { release.permissions.containsAll(it) }
|
||||
?: invalid(getString(strings.invalid_permissions_error_DESC))
|
||||
}
|
||||
|
||||
private fun getString(@StringRes id: Int): String = context.getString(id)
|
||||
}
|
||||
667
app/src/main/kotlin/com/looker/droidify/service/SyncService.kt
Normal file
667
app/src/main/kotlin/com/looker/droidify/service/SyncService.kt
Normal file
@@ -0,0 +1,667 @@
|
||||
package com.looker.droidify.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.app.job.JobInfo
|
||||
import android.app.job.JobParameters
|
||||
import android.app.job.JobService
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.view.ContextThemeWrapper
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.looker.droidify.utility.common.Constants
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.createNotificationChannel
|
||||
import com.looker.droidify.utility.common.extension.getColorFromAttr
|
||||
import com.looker.droidify.utility.common.extension.notificationManager
|
||||
import com.looker.droidify.utility.common.extension.startServiceCompat
|
||||
import com.looker.droidify.utility.common.extension.stopForegroundCompat
|
||||
import com.looker.droidify.utility.common.result.Result
|
||||
import com.looker.droidify.utility.common.sdkAbove
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.MainActivity
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.index.RepositoryUpdater
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.utility.extension.startUpdate
|
||||
import com.looker.droidify.network.DataSize
|
||||
import com.looker.droidify.network.percentBy
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
import com.looker.droidify.R
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlin.math.roundToInt
|
||||
import android.R as AndroidR
|
||||
import com.looker.droidify.R.string as stringRes
|
||||
import com.looker.droidify.R.style as styleRes
|
||||
import kotlinx.coroutines.Job as CoroutinesJob
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
|
||||
companion object {
|
||||
private const val MAX_PROGRESS = 100
|
||||
|
||||
private const val NOTIFICATION_UPDATE_SAMPLING = 400L
|
||||
|
||||
private const val MAX_UPDATE_NOTIFICATION = 5
|
||||
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
|
||||
|
||||
val syncState = MutableSharedFlow<State>()
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
sealed class State(val name: String) {
|
||||
class Connecting(appName: String) : State(appName)
|
||||
|
||||
class Syncing(
|
||||
val appName: String,
|
||||
val stage: RepositoryUpdater.Stage,
|
||||
val read: DataSize,
|
||||
val total: DataSize?
|
||||
) : State(appName)
|
||||
|
||||
data object Finish : State("")
|
||||
|
||||
val progress: Int
|
||||
get() = when (this) {
|
||||
is Connecting -> Int.MIN_VALUE
|
||||
Finish -> Int.MAX_VALUE
|
||||
is Syncing -> when(stage) {
|
||||
RepositoryUpdater.Stage.DOWNLOAD -> ((read percentBy total) * 0.4F).roundToInt()
|
||||
RepositoryUpdater.Stage.PROCESS -> 50
|
||||
RepositoryUpdater.Stage.MERGE -> 75
|
||||
RepositoryUpdater.Stage.COMMIT -> 90
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Task(val repositoryId: Long, val manual: Boolean)
|
||||
private data class CurrentTask(
|
||||
val task: Task?,
|
||||
val job: CoroutinesJob,
|
||||
val hasUpdates: Boolean,
|
||||
val lastState: State
|
||||
)
|
||||
|
||||
private enum class Started { NO, AUTO, MANUAL }
|
||||
|
||||
private var started = Started.NO
|
||||
private val tasks = mutableListOf<Task>()
|
||||
private var currentTask: CurrentTask? = null
|
||||
|
||||
private var updateNotificationBlockerFragment: WeakReference<Fragment>? = null
|
||||
|
||||
private val downloadConnection = Connection(DownloadService::class.java)
|
||||
private val lock = Mutex()
|
||||
|
||||
enum class SyncRequest { AUTO, MANUAL, FORCE }
|
||||
|
||||
inner class Binder : android.os.Binder() {
|
||||
|
||||
val state: SharedFlow<State>
|
||||
get() = syncState.asSharedFlow()
|
||||
|
||||
private fun sync(ids: List<Long>, request: SyncRequest) {
|
||||
val cancelledTask =
|
||||
cancelCurrentTask { request == SyncRequest.FORCE && it.task?.repositoryId in ids }
|
||||
cancelTasks { !it.manual && it.repositoryId in ids }
|
||||
val currentIds = tasks.asSequence().map { it.repositoryId }.toSet()
|
||||
val manual = request != SyncRequest.AUTO
|
||||
tasks += ids.asSequence().filter {
|
||||
it !in currentIds &&
|
||||
it != currentTask?.task?.repositoryId
|
||||
}.map { Task(it, manual) }
|
||||
handleNextTask(cancelledTask?.hasUpdates == true)
|
||||
if (request != SyncRequest.AUTO && started == Started.AUTO) {
|
||||
started = Started.MANUAL
|
||||
startServiceCompat()
|
||||
handleSetStarted()
|
||||
currentTask?.lastState?.let { publishForegroundState(true, it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun sync(request: SyncRequest) {
|
||||
val ids = Database.RepositoryAdapter.getAll()
|
||||
.asSequence().filter { it.enabled }.map { it.id }.toList()
|
||||
sync(ids, request)
|
||||
}
|
||||
|
||||
fun sync(repository: Repository) {
|
||||
if (repository.enabled) {
|
||||
sync(listOf(repository.id), SyncRequest.FORCE)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateAllApps() {
|
||||
val skipSignature = settingsRepository.getInitial().ignoreSignature
|
||||
updateAllAppsInternal(skipSignature)
|
||||
}
|
||||
|
||||
fun setUpdateNotificationBlocker(fragment: Fragment?) {
|
||||
updateNotificationBlockerFragment = fragment?.let(::WeakReference)
|
||||
if (fragment != null) {
|
||||
notificationManager?.cancel(Constants.NOTIFICATION_ID_UPDATES)
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(repository: Repository, enabled: Boolean): Boolean {
|
||||
Database.RepositoryAdapter.put(repository.enable(enabled))
|
||||
if (enabled) {
|
||||
val isRepoInTasks = repository.id != currentTask?.task?.repositoryId &&
|
||||
!tasks.any { it.repositoryId == repository.id }
|
||||
if (isRepoInTasks) {
|
||||
tasks += Task(repository.id, true)
|
||||
handleNextTask(false)
|
||||
}
|
||||
} else {
|
||||
cancelTasks { it.repositoryId == repository.id }
|
||||
val cancelledTask = cancelCurrentTask {
|
||||
it.task?.repositoryId == repository.id
|
||||
}
|
||||
handleNextTask(cancelledTask?.hasUpdates == true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun isCurrentlySyncing(repositoryId: Long): Boolean {
|
||||
return currentTask?.task?.repositoryId == repositoryId
|
||||
}
|
||||
|
||||
fun deleteRepository(repositoryId: Long): Boolean {
|
||||
val repository = Database.RepositoryAdapter.get(repositoryId)
|
||||
return repository != null && run {
|
||||
setEnabled(repository, false)
|
||||
Database.RepositoryAdapter.markAsDeleted(repository.id)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAuto(): Boolean {
|
||||
val removed = cancelTasks { !it.manual }
|
||||
val currentTask = cancelCurrentTask { it.task?.manual == false }
|
||||
handleNextTask(currentTask?.hasUpdates == true)
|
||||
return removed || currentTask != null
|
||||
}
|
||||
}
|
||||
|
||||
private val binder = Binder()
|
||||
override fun onBind(intent: Intent): Binder = binder
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
createNotificationChannel(
|
||||
id = Constants.NOTIFICATION_CHANNEL_SYNCING,
|
||||
name = getString(stringRes.syncing),
|
||||
)
|
||||
createNotificationChannel(
|
||||
id = Constants.NOTIFICATION_CHANNEL_UPDATES,
|
||||
name = getString(stringRes.updates),
|
||||
)
|
||||
|
||||
downloadConnection.bind(this)
|
||||
lifecycleScope.launch {
|
||||
syncState
|
||||
.sample(NOTIFICATION_UPDATE_SAMPLING)
|
||||
.collectLatest {
|
||||
publishForegroundState(false, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTimeout(startId: Int) {
|
||||
super.onTimeout(startId)
|
||||
onDestroy()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
downloadConnection.unbind(this)
|
||||
cancelTasks { true }
|
||||
cancelCurrentTask { true }
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_CANCEL) {
|
||||
tasks.clear()
|
||||
val cancelledTask = cancelCurrentTask { it.task != null }
|
||||
handleNextTask(cancelledTask?.hasUpdates == true)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun cancelTasks(condition: (Task) -> Boolean): Boolean {
|
||||
return tasks.removeAll(condition)
|
||||
}
|
||||
|
||||
private fun cancelCurrentTask(condition: ((CurrentTask) -> Boolean)): CurrentTask? {
|
||||
return currentTask?.let {
|
||||
if (condition(it)) {
|
||||
currentTask = null
|
||||
it.job.cancel()
|
||||
RepositoryUpdater.await()
|
||||
it
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotificationError(repository: Repository, exception: Exception) {
|
||||
val description = getString(
|
||||
when (exception) {
|
||||
is RepositoryUpdater.UpdateException -> when (exception.errorType) {
|
||||
RepositoryUpdater.ErrorType.NETWORK -> stringRes.network_error_DESC
|
||||
RepositoryUpdater.ErrorType.HTTP -> stringRes.http_error_DESC
|
||||
RepositoryUpdater.ErrorType.VALIDATION -> stringRes.validation_index_error_DESC
|
||||
RepositoryUpdater.ErrorType.PARSING -> stringRes.parsing_index_error_DESC
|
||||
}
|
||||
|
||||
else -> stringRes.unknown_error_DESC
|
||||
}
|
||||
)
|
||||
notificationManager?.notify(
|
||||
"repository-${repository.id}",
|
||||
Constants.NOTIFICATION_ID_SYNCING,
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING)
|
||||
.setSmallIcon(AndroidR.drawable.stat_sys_warning)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
|
||||
.getColorFromAttr(AndroidR.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.setContentTitle(getString(stringRes.could_not_sync_FORMAT, repository.name))
|
||||
.setContentText(description)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private val stateNotificationBuilder by lazy {
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING)
|
||||
.setSmallIcon(R.drawable.ic_sync)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
|
||||
.getColorFromAttr(AndroidR.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.addAction(
|
||||
0,
|
||||
getString(stringRes.cancel),
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
0,
|
||||
Intent(this, this::class.java).setAction(ACTION_CANCEL),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun publishForegroundState(force: Boolean, state: State) {
|
||||
if (force || currentTask?.lastState != state) {
|
||||
currentTask = currentTask?.copy(lastState = state)
|
||||
if (started == Started.MANUAL) {
|
||||
startForeground(
|
||||
Constants.NOTIFICATION_ID_SYNCING,
|
||||
stateNotificationBuilder.apply {
|
||||
setContentTitle(getString(stringRes.syncing_FORMAT, state.name))
|
||||
when (state) {
|
||||
is State.Connecting -> {
|
||||
setContentText(getString(stringRes.connecting))
|
||||
setProgress(0, 0, true)
|
||||
}
|
||||
|
||||
is State.Syncing -> {
|
||||
when (state.stage) {
|
||||
RepositoryUpdater.Stage.DOWNLOAD -> {
|
||||
if (state.total != null) {
|
||||
setContentText("${state.read} / ${state.total}")
|
||||
setProgress(
|
||||
MAX_PROGRESS,
|
||||
state.read percentBy state.total,
|
||||
false
|
||||
)
|
||||
} else {
|
||||
setContentText(state.read.toString())
|
||||
setProgress(0, 0, true)
|
||||
}
|
||||
}
|
||||
|
||||
RepositoryUpdater.Stage.PROCESS -> {
|
||||
val progress = (state.read percentBy state.total)
|
||||
.takeIf { it != -1 }
|
||||
setContentText(
|
||||
getString(
|
||||
stringRes.processing_FORMAT,
|
||||
"${progress ?: 0}%"
|
||||
)
|
||||
)
|
||||
setProgress(MAX_PROGRESS, progress ?: 0, progress == null)
|
||||
}
|
||||
|
||||
RepositoryUpdater.Stage.MERGE -> {
|
||||
val progress = (state.read percentBy state.total)
|
||||
setContentText(
|
||||
getString(
|
||||
stringRes.merging_FORMAT,
|
||||
"${state.read.value} / ${state.total?.value ?: state.read.value}"
|
||||
)
|
||||
)
|
||||
setProgress(MAX_PROGRESS, progress, false)
|
||||
}
|
||||
|
||||
RepositoryUpdater.Stage.COMMIT -> {
|
||||
setContentText(getString(stringRes.saving_details))
|
||||
setProgress(0, 0, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is State.Finish -> {}
|
||||
}
|
||||
}.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetStarted() {
|
||||
stateNotificationBuilder.setWhen(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
private fun handleNextTask(hasUpdates: Boolean) {
|
||||
if (currentTask != null) return
|
||||
if (tasks.isEmpty()) {
|
||||
if (started != Started.NO) {
|
||||
lifecycleScope.launch {
|
||||
val setting = settingsRepository.getInitial()
|
||||
handleUpdates(
|
||||
hasUpdates = hasUpdates,
|
||||
notifyUpdates = setting.notifyUpdate,
|
||||
autoUpdate = setting.autoUpdate,
|
||||
skipSignature = setting.ignoreSignature,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
val task = tasks.removeAt(0)
|
||||
val repository = Database.RepositoryAdapter.get(task.repositoryId)
|
||||
if (repository == null || !repository.enabled) handleNextTask(hasUpdates)
|
||||
val lastStarted = started
|
||||
val newStarted = if (task.manual || lastStarted == Started.MANUAL) {
|
||||
Started.MANUAL
|
||||
} else {
|
||||
Started.AUTO
|
||||
}
|
||||
started = newStarted
|
||||
if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) {
|
||||
startServiceCompat()
|
||||
handleSetStarted()
|
||||
}
|
||||
val initialState = State.Connecting(repository!!.name)
|
||||
publishForegroundState(true, initialState)
|
||||
lifecycleScope.launch {
|
||||
val unstableUpdates =
|
||||
settingsRepository.getInitial().unstableUpdate
|
||||
val downloadJob = downloadFile(
|
||||
task = task,
|
||||
repository = repository,
|
||||
hasUpdates = hasUpdates,
|
||||
unstableUpdates = unstableUpdates
|
||||
)
|
||||
currentTask = CurrentTask(task, downloadJob, hasUpdates, initialState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.downloadFile(
|
||||
task: Task,
|
||||
repository: Repository,
|
||||
hasUpdates: Boolean,
|
||||
unstableUpdates: Boolean
|
||||
): CoroutinesJob = launch(Dispatchers.Default) {
|
||||
var passedHasUpdates = hasUpdates
|
||||
try {
|
||||
val response = RepositoryUpdater.update(
|
||||
this@SyncService,
|
||||
repository,
|
||||
unstableUpdates
|
||||
) { stage, progress, total ->
|
||||
launch {
|
||||
syncState.emit(
|
||||
State.Syncing(
|
||||
appName = repository.name,
|
||||
stage = stage,
|
||||
read = DataSize(progress),
|
||||
total = total?.let { DataSize(it) }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
passedHasUpdates = when (response) {
|
||||
is Result.Error -> {
|
||||
response.exception?.let {
|
||||
it.printStackTrace()
|
||||
if (task.manual) showNotificationError(repository, it as Exception)
|
||||
}
|
||||
response.data == true || hasUpdates
|
||||
}
|
||||
|
||||
is Result.Success -> response.data || hasUpdates
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
lock.withLock { currentTask = null }
|
||||
handleNextTask(passedHasUpdates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleUpdates(
|
||||
hasUpdates: Boolean,
|
||||
notifyUpdates: Boolean,
|
||||
autoUpdate: Boolean,
|
||||
skipSignature: Boolean,
|
||||
) {
|
||||
try {
|
||||
if (!hasUpdates) {
|
||||
syncState.emit(State.Finish)
|
||||
val needStop = started == Started.MANUAL
|
||||
started = Started.NO
|
||||
if (needStop) stopForegroundCompat()
|
||||
return
|
||||
}
|
||||
val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true
|
||||
val updates = Database.ProductAdapter.getUpdates(skipSignature)
|
||||
if (!blocked && updates.isNotEmpty()) {
|
||||
if (notifyUpdates) displayUpdatesNotification(updates)
|
||||
if (autoUpdate) updateAllAppsInternal(skipSignature)
|
||||
}
|
||||
handleUpdates(
|
||||
hasUpdates = false,
|
||||
notifyUpdates = notifyUpdates,
|
||||
autoUpdate = autoUpdate,
|
||||
skipSignature = skipSignature,
|
||||
)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
lock.withLock { currentTask = null }
|
||||
handleNextTask(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateAllAppsInternal(skipSignature: Boolean) {
|
||||
Database.ProductAdapter
|
||||
.getUpdates(skipSignature)
|
||||
// Update Droid-ify the last
|
||||
.sortedBy { if (it.packageName == packageName) 1 else -1 }
|
||||
.map {
|
||||
Database.InstalledAdapter.get(it.packageName, null) to
|
||||
Database.RepositoryAdapter.get(it.repositoryId)
|
||||
}
|
||||
.filter { it.first != null && it.second != null }
|
||||
.forEach { (installItem, repo) ->
|
||||
val productRepo = Database.ProductAdapter.get(installItem!!.packageName, null)
|
||||
.filter { it.repositoryId == repo!!.id }
|
||||
.map { it to repo!! }
|
||||
downloadConnection.startUpdate(
|
||||
installItem.packageName,
|
||||
installItem,
|
||||
productRepo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayUpdatesNotification(productItems: List<ProductItem>) {
|
||||
notificationManager?.notify(
|
||||
Constants.NOTIFICATION_ID_UPDATES,
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_UPDATES)
|
||||
.setSmallIcon(R.drawable.ic_new_releases)
|
||||
.setContentTitle(getString(stringRes.new_updates_available))
|
||||
.setContentText(
|
||||
resources.getQuantityString(
|
||||
R.plurals.new_updates_DESC_FORMAT,
|
||||
productItems.size,
|
||||
productItems.size
|
||||
)
|
||||
)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
|
||||
.getColorFromAttr(AndroidR.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java)
|
||||
.setAction(MainActivity.ACTION_UPDATES),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
.setStyle(
|
||||
NotificationCompat.InboxStyle().also {
|
||||
for (productItem in productItems.take(MAX_UPDATE_NOTIFICATION)) {
|
||||
val builder = SpannableStringBuilder(productItem.name)
|
||||
builder.setSpan(
|
||||
ForegroundColorSpan(Color.BLACK),
|
||||
0,
|
||||
builder.length,
|
||||
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
builder.append(' ').append(productItem.version)
|
||||
it.addLine(builder)
|
||||
}
|
||||
if (productItems.size > MAX_UPDATE_NOTIFICATION) {
|
||||
val summary =
|
||||
getString(
|
||||
stringRes.plus_more_FORMAT,
|
||||
productItems.size - MAX_UPDATE_NOTIFICATION
|
||||
)
|
||||
if (SdkCheck.isNougat) {
|
||||
it.addLine(summary)
|
||||
} else {
|
||||
it.setSummaryText(summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("SpecifyJobSchedulerIdRange")
|
||||
class Job : JobService() {
|
||||
private val jobScope = CoroutineScope(Dispatchers.Default)
|
||||
private var syncParams: JobParameters? = null
|
||||
private val syncConnection =
|
||||
Connection(SyncService::class.java, onBind = { connection, binder ->
|
||||
jobScope.launch {
|
||||
binder.state.filter { it is State.Finish }.collect {
|
||||
val params = syncParams
|
||||
if (params != null) {
|
||||
syncParams = null
|
||||
connection.unbind(this@Job)
|
||||
jobFinished(params, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
binder.sync(SyncRequest.AUTO)
|
||||
}, onUnbind = { _, binder ->
|
||||
binder.cancelAuto()
|
||||
jobScope.cancel()
|
||||
val params = syncParams
|
||||
if (params != null) {
|
||||
syncParams = null
|
||||
jobFinished(params, true)
|
||||
}
|
||||
})
|
||||
|
||||
override fun onStartJob(params: JobParameters): Boolean {
|
||||
syncParams = params
|
||||
syncConnection.bind(this)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onStopJob(params: JobParameters): Boolean {
|
||||
syncParams = null
|
||||
jobScope.cancel()
|
||||
val reschedule = syncConnection.binder?.cancelAuto() == true
|
||||
syncConnection.unbind(this)
|
||||
return reschedule
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
context: Context,
|
||||
periodMillis: Long,
|
||||
networkType: Int,
|
||||
isCharging: Boolean,
|
||||
isBatteryLow: Boolean
|
||||
): JobInfo = JobInfo.Builder(
|
||||
Constants.JOB_ID_SYNC,
|
||||
ComponentName(context, Job::class.java)
|
||||
).apply {
|
||||
setRequiredNetworkType(networkType)
|
||||
sdkAbove(sdk = Build.VERSION_CODES.O) {
|
||||
setRequiresCharging(isCharging)
|
||||
setRequiresBatteryNotLow(isBatteryLow)
|
||||
setRequiresStorageNotLow(true)
|
||||
}
|
||||
if (SdkCheck.isNougat) {
|
||||
setPeriodic(periodMillis, JobInfo.getMinFlexMillis())
|
||||
} else {
|
||||
setPeriodic(periodMillis)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.looker.droidify.sync
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.network.validation.ValidationException
|
||||
import java.util.jar.JarEntry
|
||||
|
||||
interface IndexValidator {
|
||||
|
||||
@Throws(ValidationException::class)
|
||||
suspend fun validate(
|
||||
jarEntry: JarEntry,
|
||||
expectedFingerprint: Fingerprint?,
|
||||
): Fingerprint
|
||||
|
||||
}
|
||||
14
app/src/main/kotlin/com/looker/droidify/sync/Parser.kt
Normal file
14
app/src/main/kotlin/com/looker/droidify/sync/Parser.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.looker.droidify.sync
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import java.io.File
|
||||
|
||||
interface Parser<out T> {
|
||||
|
||||
suspend fun parse(
|
||||
file: File,
|
||||
repo: Repo,
|
||||
): Pair<Fingerprint, T>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.looker.droidify.sync
|
||||
|
||||
import android.app.job.JobInfo
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.NetworkType
|
||||
|
||||
data class SyncPreference(
|
||||
val networkType: NetworkType,
|
||||
val pluggedIn: Boolean = false,
|
||||
val batteryNotLow: Boolean = true,
|
||||
)
|
||||
|
||||
fun SyncPreference.toJobNetworkType() = when (networkType) {
|
||||
NetworkType.NOT_REQUIRED -> JobInfo.NETWORK_TYPE_NONE
|
||||
NetworkType.UNMETERED -> JobInfo.NETWORK_TYPE_UNMETERED
|
||||
else -> JobInfo.NETWORK_TYPE_ANY
|
||||
}
|
||||
|
||||
fun SyncPreference.toWorkConstraints(): Constraints = Constraints(
|
||||
requiredNetworkType = networkType,
|
||||
requiresCharging = pluggedIn,
|
||||
requiresBatteryNotLow = batteryNotLow
|
||||
)
|
||||
20
app/src/main/kotlin/com/looker/droidify/sync/Syncable.kt
Normal file
20
app/src/main/kotlin/com/looker/droidify/sync/Syncable.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.looker.droidify.sync
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
|
||||
/**
|
||||
* Expected Architecture: [https://excalidraw.com/#json=JqpGunWTJONjq-ecDNiPg,j9t0X4coeNvIG7B33GTq6A]
|
||||
*
|
||||
* Current Issue: When downloading entry.jar we need to re-call the synchronizer,
|
||||
* which this arch doesn't allow.
|
||||
*/
|
||||
interface Syncable<T> {
|
||||
|
||||
val parser: Parser<T>
|
||||
|
||||
suspend fun sync(
|
||||
repo: Repo,
|
||||
): Pair<Fingerprint, com.looker.droidify.sync.v2.model.IndexV2?>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package com.looker.droidify.sync.common
|
||||
|
||||
import com.looker.droidify.sync.v1.model.AppV1
|
||||
import com.looker.droidify.sync.v1.model.IndexV1
|
||||
import com.looker.droidify.sync.v1.model.Localized
|
||||
import com.looker.droidify.sync.v1.model.PackageV1
|
||||
import com.looker.droidify.sync.v1.model.RepoV1
|
||||
import com.looker.droidify.sync.v1.model.maxSdk
|
||||
import com.looker.droidify.sync.v1.model.name
|
||||
import com.looker.droidify.sync.v2.model.AntiFeatureV2
|
||||
import com.looker.droidify.sync.v2.model.CategoryV2
|
||||
import com.looker.droidify.sync.v2.model.FeatureV2
|
||||
import com.looker.droidify.sync.v2.model.FileV2
|
||||
import com.looker.droidify.sync.v2.model.IndexV2
|
||||
import com.looker.droidify.sync.v2.model.LocalizedFiles
|
||||
import com.looker.droidify.sync.v2.model.LocalizedIcon
|
||||
import com.looker.droidify.sync.v2.model.LocalizedString
|
||||
import com.looker.droidify.sync.v2.model.ManifestV2
|
||||
import com.looker.droidify.sync.v2.model.MetadataV2
|
||||
import com.looker.droidify.sync.v2.model.MirrorV2
|
||||
import com.looker.droidify.sync.v2.model.PackageV2
|
||||
import com.looker.droidify.sync.v2.model.PermissionV2
|
||||
import com.looker.droidify.sync.v2.model.RepoV2
|
||||
import com.looker.droidify.sync.v2.model.ScreenshotsV2
|
||||
import com.looker.droidify.sync.v2.model.SignerV2
|
||||
import com.looker.droidify.sync.v2.model.UsesSdkV2
|
||||
import com.looker.droidify.sync.v2.model.VersionV2
|
||||
|
||||
private const val V1_LOCALE = "en-US"
|
||||
|
||||
internal fun IndexV1.toV2(): IndexV2 {
|
||||
val antiFeatures: MutableList<String> = mutableListOf()
|
||||
val categories: MutableList<String> = mutableListOf()
|
||||
|
||||
val packagesV2: HashMap<String, PackageV2> = hashMapOf()
|
||||
|
||||
apps.forEach { app ->
|
||||
antiFeatures.addAll(app.antiFeatures)
|
||||
categories.addAll(app.categories)
|
||||
val versions = packages[app.packageName]
|
||||
val preferredSigner = versions?.firstOrNull()?.signer
|
||||
val whatsNew: LocalizedString? = app.localized
|
||||
?.localizedString(null) { it.whatsNew }
|
||||
val packageV2 = PackageV2(
|
||||
versions = versions?.associate { packageV1 ->
|
||||
packageV1.hash to packageV1.toVersionV2(
|
||||
whatsNew = whatsNew,
|
||||
packageAntiFeatures = app.antiFeatures + (packageV1.antiFeatures ?: emptyList())
|
||||
)
|
||||
} ?: emptyMap(),
|
||||
metadata = app.toV2(preferredSigner)
|
||||
)
|
||||
packagesV2.putIfAbsent(app.packageName, packageV2)
|
||||
}
|
||||
|
||||
return IndexV2(
|
||||
repo = repo.toRepoV2(
|
||||
categories = categories,
|
||||
antiFeatures = antiFeatures
|
||||
),
|
||||
packages = packagesV2,
|
||||
)
|
||||
}
|
||||
|
||||
private fun RepoV1.toRepoV2(
|
||||
categories: List<String>,
|
||||
antiFeatures: List<String>,
|
||||
): RepoV2 = RepoV2(
|
||||
address = address,
|
||||
timestamp = timestamp,
|
||||
icon = mapOf(V1_LOCALE to FileV2("/icons/$icon")),
|
||||
name = mapOf(V1_LOCALE to name),
|
||||
description = mapOf(V1_LOCALE to description),
|
||||
mirrors = mirrors.toMutableList()
|
||||
.apply { add(0, address) }
|
||||
.map { MirrorV2(url = it, isPrimary = (it == address).takeIf { it }) },
|
||||
antiFeatures = antiFeatures.associateWith { name ->
|
||||
AntiFeatureV2(
|
||||
name = mapOf(V1_LOCALE to name),
|
||||
icon = mapOf(V1_LOCALE to FileV2("/icons/ic_antifeature_${name.normalizeName()}.png")),
|
||||
)
|
||||
},
|
||||
categories = categories.associateWith { name ->
|
||||
CategoryV2(
|
||||
name = mapOf(V1_LOCALE to name),
|
||||
icon = mapOf(V1_LOCALE to FileV2("/icons/category_${name.normalizeName()}.png")),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun String.normalizeName(): String = lowercase().replace(" & ", "_")
|
||||
|
||||
private fun AppV1.toV2(preferredSigner: String?): MetadataV2 = MetadataV2(
|
||||
added = added ?: 0L,
|
||||
lastUpdated = lastUpdated ?: 0L,
|
||||
icon = localized?.localizedIcon(packageName, icon) { it.icon },
|
||||
name = localized?.localizedString(name) { it.name },
|
||||
description = localized?.localizedString(description) { it.description },
|
||||
summary = localized?.localizedString(summary) { it.summary },
|
||||
authorEmail = authorEmail,
|
||||
authorName = authorName,
|
||||
authorPhone = authorPhone,
|
||||
authorWebSite = authorWebSite,
|
||||
bitcoin = bitcoin,
|
||||
categories = categories,
|
||||
changelog = changelog,
|
||||
donate = if (donate != null) listOf(donate) else emptyList(),
|
||||
featureGraphic = localized?.localizedIcon(packageName) { it.featureGraphic },
|
||||
flattrID = flattrID,
|
||||
issueTracker = issueTracker,
|
||||
liberapay = liberapay,
|
||||
license = license,
|
||||
litecoin = litecoin,
|
||||
openCollective = openCollective,
|
||||
preferredSigner = preferredSigner,
|
||||
promoGraphic = localized?.localizedIcon(packageName) { it.promoGraphic },
|
||||
screenshots = localized?.screenshotV2(packageName),
|
||||
sourceCode = sourceCode,
|
||||
translation = translation,
|
||||
tvBanner = localized?.localizedIcon(packageName) { it.tvBanner },
|
||||
video = localized?.localizedString(null) { it.video },
|
||||
webSite = webSite,
|
||||
)
|
||||
|
||||
private fun Map<String, Localized>.screenshotV2(
|
||||
packageName: String,
|
||||
): ScreenshotsV2? = ScreenshotsV2(
|
||||
phone = localizedScreenshots { locale, screenshot ->
|
||||
screenshot.phoneScreenshots?.map {
|
||||
"/$packageName/$locale/phoneScreenshots/$it"
|
||||
}
|
||||
},
|
||||
sevenInch = localizedScreenshots { locale, screenshot ->
|
||||
screenshot.sevenInchScreenshots?.map {
|
||||
"/$packageName/$locale/sevenInchScreenshots/$it"
|
||||
}
|
||||
},
|
||||
tenInch = localizedScreenshots { locale, screenshot ->
|
||||
screenshot.tenInchScreenshots?.map {
|
||||
"/$packageName/$locale/tenInchScreenshots/$it"
|
||||
}
|
||||
},
|
||||
tv = localizedScreenshots { locale, screenshot ->
|
||||
screenshot.tvScreenshots?.map {
|
||||
"/$packageName/$locale/tvScreenshots/$it"
|
||||
}
|
||||
},
|
||||
wear = localizedScreenshots { locale, screenshot ->
|
||||
screenshot.wearScreenshots?.map {
|
||||
"/$packageName/$locale/wearScreenshots/$it"
|
||||
}
|
||||
},
|
||||
).takeIf { !it.isNull }
|
||||
|
||||
private fun PackageV1.toVersionV2(
|
||||
whatsNew: LocalizedString?,
|
||||
packageAntiFeatures: List<String>,
|
||||
): VersionV2 = VersionV2(
|
||||
added = added ?: 0L,
|
||||
file = FileV2(
|
||||
name = "/$apkName",
|
||||
sha256 = hash,
|
||||
size = size,
|
||||
),
|
||||
src = srcName?.let { FileV2("/$it") },
|
||||
whatsNew = whatsNew ?: emptyMap(),
|
||||
antiFeatures = packageAntiFeatures.associateWith { mapOf(V1_LOCALE to it) },
|
||||
manifest = ManifestV2(
|
||||
versionName = versionName,
|
||||
versionCode = versionCode ?: 0L,
|
||||
signer = signer?.let { SignerV2(listOf(it)) },
|
||||
usesSdk = sdkV2(),
|
||||
maxSdkVersion = maxSdkVersion,
|
||||
usesPermission = usesPermission.map { PermissionV2(it.name, it.maxSdk) },
|
||||
usesPermissionSdk23 = usesPermission23.map { PermissionV2(it.name, it.maxSdk) },
|
||||
features = features?.map { FeatureV2(it) } ?: emptyList(),
|
||||
nativecode = nativeCode ?: emptyList()
|
||||
),
|
||||
)
|
||||
|
||||
private fun PackageV1.sdkV2(): UsesSdkV2? {
|
||||
return if (minSdkVersion == null && targetSdkVersion == null) {
|
||||
null
|
||||
} else {
|
||||
UsesSdkV2(
|
||||
minSdkVersion = minSdkVersion ?: 1,
|
||||
targetSdkVersion = targetSdkVersion ?: 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun Map<String, Localized>.localizedString(
|
||||
default: String?,
|
||||
crossinline block: (Localized) -> String?,
|
||||
): LocalizedString? {
|
||||
// Because top level fields are null if there are localized fields underneath
|
||||
// Turns out no
|
||||
if (isEmpty() && default != null) {
|
||||
return mapOf(V1_LOCALE to default)
|
||||
}
|
||||
val checkDefault = get(V1_LOCALE)?.let { block(it) }
|
||||
if (checkDefault == null && default != null) {
|
||||
return mapOf(V1_LOCALE to default)
|
||||
}
|
||||
return mapValuesNotNull { (_, localized) ->
|
||||
block(localized)
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
|
||||
private inline fun Map<String, Localized>.localizedIcon(
|
||||
packageName: String,
|
||||
default: String? = null,
|
||||
crossinline block: (Localized) -> String?,
|
||||
): LocalizedIcon? {
|
||||
if (isEmpty() && default != null) {
|
||||
return mapOf(V1_LOCALE to FileV2("/icons/$default"))
|
||||
}
|
||||
val checkDefault = get(V1_LOCALE)?.let { block(it) }
|
||||
if (checkDefault == null && default != null) {
|
||||
return mapOf(V1_LOCALE to FileV2("/icons/$default"))
|
||||
}
|
||||
return mapValuesNotNull { (locale, localized) ->
|
||||
block(localized)?.let {
|
||||
FileV2("/$packageName/$locale/$it")
|
||||
}
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private inline fun Map<String, Localized>.localizedScreenshots(
|
||||
crossinline block: (String, Localized) -> List<String>?,
|
||||
): LocalizedFiles? {
|
||||
return mapValuesNotNull { (locale, localized) ->
|
||||
val files = block(locale, localized)
|
||||
if (files.isNullOrEmpty()) null
|
||||
else files.map(::FileV2)
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private inline fun <K, V, M> Map<K, V>.mapValuesNotNull(
|
||||
block: (Map.Entry<K, V>) -> M?
|
||||
): Map<K, M> {
|
||||
val map = HashMap<K, M>()
|
||||
forEach { entry ->
|
||||
block(entry)?.let { map[entry.key] = it }
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.looker.droidify.sync.common
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.network.Downloader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
|
||||
suspend fun Downloader.downloadIndex(
|
||||
context: Context,
|
||||
repo: Repo,
|
||||
fileName: String,
|
||||
url: String,
|
||||
diff: Boolean = false,
|
||||
): File = withContext(Dispatchers.IO) {
|
||||
val tempFile = Cache.getIndexFile(context, "repo_${repo.id}_$fileName")
|
||||
downloadToFile(
|
||||
url = url,
|
||||
target = tempFile,
|
||||
headers = {
|
||||
if (repo.shouldAuthenticate) {
|
||||
authentication(
|
||||
repo.authentication.username,
|
||||
repo.authentication.password
|
||||
)
|
||||
}
|
||||
if (repo.versionInfo.timestamp > 0L && !diff) {
|
||||
ifModifiedSince(Date(repo.versionInfo.timestamp))
|
||||
}
|
||||
}
|
||||
)
|
||||
tempFile
|
||||
}
|
||||
|
||||
const val INDEX_V1_NAME = "index-v1.jar"
|
||||
const val ENTRY_V2_NAME = "entry.jar"
|
||||
const val INDEX_V2_NAME = "index-v2.json"
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.looker.droidify.sync.common
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.check
|
||||
import com.looker.droidify.domain.model.fingerprint
|
||||
import com.looker.droidify.network.validation.invalid
|
||||
import com.looker.droidify.sync.utils.certificate
|
||||
import com.looker.droidify.sync.utils.codeSigner
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.jar.JarEntry
|
||||
|
||||
class IndexJarValidator(
|
||||
private val dispatcher: CoroutineDispatcher
|
||||
) : com.looker.droidify.sync.IndexValidator {
|
||||
override suspend fun validate(
|
||||
jarEntry: JarEntry,
|
||||
expectedFingerprint: Fingerprint?
|
||||
): Fingerprint = withContext(dispatcher) {
|
||||
val fingerprint = try {
|
||||
jarEntry
|
||||
.codeSigner
|
||||
.certificate
|
||||
.fingerprint()
|
||||
} catch (e: IllegalStateException) {
|
||||
invalid(e.message ?: "Unknown Exception")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
invalid(e.message ?: "Error creating Fingerprint object")
|
||||
}
|
||||
if (expectedFingerprint == null) {
|
||||
fingerprint
|
||||
} else {
|
||||
if (expectedFingerprint.check(fingerprint)) {
|
||||
expectedFingerprint
|
||||
} else {
|
||||
invalid(
|
||||
"Expected Fingerprint: ${expectedFingerprint}, " +
|
||||
"Acquired Fingerprint: $fingerprint"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.looker.droidify.sync.common
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
val JsonParser = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
isLenient = true
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.looker.droidify.sync.utils
|
||||
|
||||
import java.io.File
|
||||
import java.security.CodeSigner
|
||||
import java.security.cert.Certificate
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarFile
|
||||
|
||||
fun File.toJarFile(verify: Boolean = true): JarFile = JarFile(this, verify)
|
||||
|
||||
@get:Throws(IllegalStateException::class)
|
||||
val JarEntry.codeSigner: CodeSigner
|
||||
get() = codeSigners?.singleOrNull()
|
||||
?: error("index.jar must be signed by a single code signer, Current: $codeSigners")
|
||||
|
||||
@get:Throws(IllegalStateException::class)
|
||||
val CodeSigner.certificate: Certificate
|
||||
get() = signerCertPath?.certificates?.singleOrNull()
|
||||
?: error("index.jar code signer should have only one certificate")
|
||||
31
app/src/main/kotlin/com/looker/droidify/sync/v1/V1Parser.kt
Normal file
31
app/src/main/kotlin/com/looker/droidify/sync/v1/V1Parser.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.looker.droidify.sync.v1
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.sync.IndexValidator
|
||||
import com.looker.droidify.sync.Parser
|
||||
import com.looker.droidify.sync.utils.toJarFile
|
||||
import com.looker.droidify.sync.v1.model.IndexV1
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
class V1Parser(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
private val json: Json,
|
||||
private val validator: IndexValidator,
|
||||
) : Parser<IndexV1> {
|
||||
|
||||
override suspend fun parse(
|
||||
file: File,
|
||||
repo: Repo,
|
||||
): Pair<Fingerprint, IndexV1> = withContext(dispatcher) {
|
||||
val jar = file.toJarFile()
|
||||
val entry = jar.getJarEntry("index-v1.json")
|
||||
val indexString = jar.getInputStream(entry).use {
|
||||
it.readBytes().decodeToString()
|
||||
}
|
||||
validator.validate(entry, repo.fingerprint) to json.decodeFromString(indexString)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.looker.droidify.sync.v1
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.sync.Parser
|
||||
import com.looker.droidify.sync.Syncable
|
||||
import com.looker.droidify.sync.common.INDEX_V1_NAME
|
||||
import com.looker.droidify.sync.common.IndexJarValidator
|
||||
import com.looker.droidify.sync.common.JsonParser
|
||||
import com.looker.droidify.sync.common.downloadIndex
|
||||
import com.looker.droidify.sync.common.toV2
|
||||
import com.looker.droidify.sync.v1.model.IndexV1
|
||||
import com.looker.droidify.sync.v2.model.IndexV2
|
||||
import com.looker.droidify.network.Downloader
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class V1Syncable(
|
||||
private val context: Context,
|
||||
private val downloader: Downloader,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
) : Syncable<IndexV1> {
|
||||
override val parser: Parser<IndexV1>
|
||||
get() = V1Parser(
|
||||
dispatcher = dispatcher,
|
||||
json = JsonParser,
|
||||
validator = IndexJarValidator(dispatcher),
|
||||
)
|
||||
|
||||
override suspend fun sync(repo: Repo): Pair<Fingerprint, IndexV2> =
|
||||
withContext(dispatcher) {
|
||||
val jar = downloader.downloadIndex(
|
||||
context = context,
|
||||
repo = repo,
|
||||
url = repo.address.removeSuffix("/") + "/$INDEX_V1_NAME",
|
||||
fileName = INDEX_V1_NAME,
|
||||
)
|
||||
val (fingerprint, indexV1) = parser.parse(jar, repo)
|
||||
jar.delete()
|
||||
fingerprint to indexV1.toV2()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.looker.droidify.sync.v1.model
|
||||
|
||||
/*
|
||||
* AppV1 is licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AppV1(
|
||||
val packageName: String,
|
||||
val icon: String? = null,
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val summary: String? = null,
|
||||
val added: Long? = null,
|
||||
val antiFeatures: List<String> = emptyList(),
|
||||
val authorEmail: String? = null,
|
||||
val authorName: String? = null,
|
||||
val authorPhone: String? = null,
|
||||
val authorWebSite: String? = null,
|
||||
val binaries: String? = null,
|
||||
val bitcoin: String? = null,
|
||||
val categories: List<String> = emptyList(),
|
||||
val changelog: String? = null,
|
||||
val donate: String? = null,
|
||||
val flattrID: String? = null,
|
||||
val issueTracker: String? = null,
|
||||
val lastUpdated: Long? = null,
|
||||
val liberapay: String? = null,
|
||||
val liberapayID: String? = null,
|
||||
val license: String,
|
||||
val litecoin: String? = null,
|
||||
val localized: Map<String, Localized>? = null,
|
||||
val openCollective: String? = null,
|
||||
val sourceCode: String? = null,
|
||||
val suggestedVersionCode: String? = null,
|
||||
val translation: String? = null,
|
||||
val webSite: String? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.looker.droidify.sync.v1.model
|
||||
|
||||
/*
|
||||
* IndexV1 is licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class IndexV1(
|
||||
val repo: RepoV1,
|
||||
val apps: List<AppV1> = emptyList(),
|
||||
val packages: Map<String, List<PackageV1>> = emptyMap(),
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.looker.droidify.sync.v1.model
|
||||
|
||||
/*
|
||||
* Localized is licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Localized(
|
||||
val icon: String? = null,
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val summary: String? = null,
|
||||
val featureGraphic: String? = null,
|
||||
val phoneScreenshots: List<String>? = null,
|
||||
val promoGraphic: String? = null,
|
||||
val sevenInchScreenshots: List<String>? = null,
|
||||
val tenInchScreenshots: List<String>? = null,
|
||||
val tvBanner: String? = null,
|
||||
val tvScreenshots: List<String>? = null,
|
||||
val video: String? = null,
|
||||
val wearScreenshots: List<String>? = null,
|
||||
val whatsNew: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.looker.droidify.sync.v1.model
|
||||
|
||||
/*
|
||||
* PackageV1, PermissionV1 are licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PackageV1(
|
||||
val added: Long? = null,
|
||||
val apkName: String,
|
||||
val hash: String,
|
||||
val hashType: String,
|
||||
val minSdkVersion: Int? = null,
|
||||
val maxSdkVersion: Int? = null,
|
||||
val targetSdkVersion: Int? = minSdkVersion,
|
||||
val packageName: String,
|
||||
val sig: String? = null,
|
||||
val signer: String? = null,
|
||||
val size: Long,
|
||||
@SerialName("srcname")
|
||||
val srcName: String? = null,
|
||||
@SerialName("uses-permission")
|
||||
val usesPermission: List<PermissionV1> = emptyList(),
|
||||
@SerialName("uses-permission-sdk-23")
|
||||
val usesPermission23: List<PermissionV1> = emptyList(),
|
||||
val versionCode: Long? = null,
|
||||
val versionName: String,
|
||||
@SerialName("nativecode")
|
||||
val nativeCode: List<String>? = null,
|
||||
val features: List<String>? = null,
|
||||
val antiFeatures: List<String>? = null,
|
||||
)
|
||||
|
||||
typealias PermissionV1 = Array<String?>
|
||||
|
||||
val PermissionV1.name: String get() = first()!!
|
||||
val PermissionV1.maxSdk: Int? get() = getOrNull(1)?.toInt()
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.looker.droidify.sync.v1.model
|
||||
|
||||
/*
|
||||
* RepoV1 is licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class RepoV1(
|
||||
val address: String,
|
||||
val icon: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val timestamp: Long,
|
||||
val version: Int,
|
||||
val mirrors: List<String> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.looker.droidify.sync.v2
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.sync.Parser
|
||||
import com.looker.droidify.sync.v2.model.IndexV2Diff
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
class DiffParser(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
private val json: Json,
|
||||
) : Parser<IndexV2Diff> {
|
||||
|
||||
override suspend fun parse(
|
||||
file: File,
|
||||
repo: Repo
|
||||
): Pair<Fingerprint, IndexV2Diff> = withContext(dispatcher) {
|
||||
requireNotNull(repo.fingerprint) {
|
||||
"Fingerprint should not be null when parsing diff"
|
||||
} to json.decodeFromString(file.readBytes().decodeToString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.looker.droidify.sync.v2
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.sync.IndexValidator
|
||||
import com.looker.droidify.sync.Parser
|
||||
import com.looker.droidify.sync.utils.toJarFile
|
||||
import com.looker.droidify.sync.v2.model.Entry
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
class EntryParser(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
private val json: Json,
|
||||
private val validator: IndexValidator,
|
||||
) : Parser<Entry> {
|
||||
|
||||
override suspend fun parse(
|
||||
file: File,
|
||||
repo: Repo,
|
||||
): Pair<Fingerprint, Entry> = withContext(dispatcher) {
|
||||
val jar = file.toJarFile()
|
||||
val entry = jar.getJarEntry("entry.json")
|
||||
val entryString = jar.getInputStream(entry).use {
|
||||
it.readBytes().decodeToString()
|
||||
}
|
||||
validator.validate(entry, repo.fingerprint) to json.decodeFromString(entryString)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.looker.droidify.sync.v2
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.sync.Parser
|
||||
import com.looker.droidify.sync.Syncable
|
||||
import com.looker.droidify.network.Downloader
|
||||
import com.looker.droidify.sync.common.ENTRY_V2_NAME
|
||||
import com.looker.droidify.sync.common.INDEX_V2_NAME
|
||||
import com.looker.droidify.sync.common.IndexJarValidator
|
||||
import com.looker.droidify.sync.common.JsonParser
|
||||
import com.looker.droidify.sync.common.downloadIndex
|
||||
import com.looker.droidify.sync.v2.model.Entry
|
||||
import com.looker.droidify.sync.v2.model.IndexV2
|
||||
import com.looker.droidify.sync.v2.model.IndexV2Diff
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
|
||||
class EntrySyncable(
|
||||
private val context: Context,
|
||||
private val downloader: Downloader,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
) : Syncable<Entry> {
|
||||
override val parser: Parser<Entry>
|
||||
get() = EntryParser(
|
||||
dispatcher = dispatcher,
|
||||
json = JsonParser,
|
||||
validator = IndexJarValidator(dispatcher),
|
||||
)
|
||||
|
||||
private val indexParser: Parser<IndexV2> = V2Parser(
|
||||
dispatcher = dispatcher,
|
||||
json = JsonParser,
|
||||
)
|
||||
|
||||
private val diffParser: Parser<IndexV2Diff> = DiffParser(
|
||||
dispatcher = dispatcher,
|
||||
json = JsonParser,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
override suspend fun sync(repo: Repo): Pair<Fingerprint, IndexV2?> =
|
||||
withContext(dispatcher) {
|
||||
// example https://apt.izzysoft.de/fdroid/repo/entry.json
|
||||
val jar = downloader.downloadIndex(
|
||||
context = context,
|
||||
repo = repo,
|
||||
url = repo.address.removeSuffix("/") + "/$ENTRY_V2_NAME",
|
||||
fileName = ENTRY_V2_NAME
|
||||
)
|
||||
val (fingerprint, entry) = parser.parse(jar, repo)
|
||||
jar.delete()
|
||||
val index = entry.getDiff(repo.versionInfo.timestamp)
|
||||
// Already latest
|
||||
?: return@withContext fingerprint to null
|
||||
val indexPath = repo.address.removeSuffix("/") + index.name
|
||||
val indexFile = Cache.getIndexFile(context, "repo_${repo.id}_$INDEX_V2_NAME")
|
||||
val indexV2 = if (index != entry.index && indexFile.exists()) {
|
||||
// example https://apt.izzysoft.de/fdroid/repo/diff/1725372028000.json
|
||||
val diffFile = downloader.downloadIndex(
|
||||
context = context,
|
||||
repo = repo,
|
||||
url = indexPath,
|
||||
fileName = "diff_${repo.versionInfo.timestamp}.json",
|
||||
diff = true,
|
||||
)
|
||||
// TODO: Maybe parse in parallel
|
||||
diffParser.parse(diffFile, repo).second.let {
|
||||
diffFile.delete()
|
||||
it.patchInto(
|
||||
indexParser.parse(
|
||||
indexFile,
|
||||
repo
|
||||
).second) { index ->
|
||||
Json.encodeToStream(index, indexFile.outputStream())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// example https://apt.izzysoft.de/fdroid/repo/index-v2.json
|
||||
val newIndexFile = downloader.downloadIndex(
|
||||
context = context,
|
||||
repo = repo,
|
||||
url = indexPath,
|
||||
fileName = INDEX_V2_NAME,
|
||||
)
|
||||
indexParser.parse(newIndexFile, repo).second
|
||||
}
|
||||
fingerprint to indexV2
|
||||
}
|
||||
}
|
||||
25
app/src/main/kotlin/com/looker/droidify/sync/v2/V2Parser.kt
Normal file
25
app/src/main/kotlin/com/looker/droidify/sync/v2/V2Parser.kt
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.looker.droidify.sync.v2
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.sync.Parser
|
||||
import com.looker.droidify.sync.v2.model.IndexV2
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
class V2Parser(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
private val json: Json,
|
||||
) : Parser<IndexV2> {
|
||||
|
||||
override suspend fun parse(
|
||||
file: File,
|
||||
repo: Repo,
|
||||
): Pair<Fingerprint, IndexV2> = withContext(dispatcher) {
|
||||
requireNotNull(repo.fingerprint) {
|
||||
"Fingerprint should not be null if index v2 is being fetched"
|
||||
} to json.decodeFromString(file.readBytes().decodeToString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.looker.droidify.sync.v2.model
|
||||
|
||||
/*
|
||||
* Entry and EntryFile are licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Entry(
|
||||
val timestamp: Long,
|
||||
val version: Long,
|
||||
val index: EntryFile,
|
||||
val diffs: Map<Long, EntryFile>
|
||||
) {
|
||||
|
||||
fun getDiff(timestamp: Long): EntryFile? {
|
||||
return if (this.timestamp == timestamp) null
|
||||
else diffs[timestamp] ?: index
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class EntryFile(
|
||||
val name: String,
|
||||
val sha256: String,
|
||||
val size: Long,
|
||||
val numPackages: Long,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.looker.droidify.sync.v2.model
|
||||
|
||||
/*
|
||||
* FileV2 is licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class FileV2(
|
||||
val name: String,
|
||||
val sha256: String? = null,
|
||||
val size: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.looker.droidify.sync.v2.model
|
||||
|
||||
/*
|
||||
* IndexV2, RepoV2 are licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class IndexV2(
|
||||
val repo: RepoV2,
|
||||
val packages: Map<String, PackageV2>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class IndexV2Diff(
|
||||
val repo: RepoV2Diff,
|
||||
val packages: Map<String, PackageV2Diff?>
|
||||
) {
|
||||
fun patchInto(index: IndexV2, saveIndex: (IndexV2) -> Unit): IndexV2 {
|
||||
val packagesToRemove = packages.filter { it.value == null }.keys
|
||||
val packagesToAdd = packages
|
||||
.mapNotNull { (key, value) ->
|
||||
value?.let { value ->
|
||||
if (index.packages.keys.contains(key))
|
||||
index.packages[key]?.let { value.patchInto(it) }
|
||||
else value.toPackage()
|
||||
}?.let { key to it }
|
||||
}
|
||||
|
||||
val newIndex = index.copy(
|
||||
repo = repo.patchInto(index.repo),
|
||||
packages = index.packages.minus(packagesToRemove).plus(packagesToAdd),
|
||||
)
|
||||
saveIndex(newIndex)
|
||||
return newIndex
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.looker.droidify.sync.v2.model
|
||||
|
||||
typealias LocalizedString = Map<String, String>
|
||||
typealias NullableLocalizedString = Map<String, String?>
|
||||
typealias LocalizedIcon = Map<String, FileV2>
|
||||
typealias LocalizedList = Map<String, List<String>>
|
||||
typealias LocalizedFiles = Map<String, List<FileV2>>
|
||||
@@ -0,0 +1,275 @@
|
||||
package com.looker.droidify.sync.v2.model
|
||||
|
||||
/*
|
||||
* PackageV2, MetadataV2, VersionV2, ManifestV2, UsesSdkV2, PermissionV2, FeatureV2, SignerV2
|
||||
* are licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PackageV2(
|
||||
val metadata: MetadataV2,
|
||||
val versions: Map<String, VersionV2>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PackageV2Diff(
|
||||
val metadata: MetadataV2Diff?,
|
||||
val versions: Map<String, VersionV2Diff?>? = null,
|
||||
) {
|
||||
fun toPackage(): PackageV2 = PackageV2(
|
||||
metadata = MetadataV2(
|
||||
added = metadata?.added ?: 0L,
|
||||
lastUpdated = metadata?.lastUpdated ?: 0L,
|
||||
name = metadata?.name
|
||||
?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap(),
|
||||
summary = metadata?.summary
|
||||
?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap(),
|
||||
description = metadata?.description
|
||||
?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap(),
|
||||
icon = metadata?.icon,
|
||||
authorEmail = metadata?.authorEmail,
|
||||
authorName = metadata?.authorName,
|
||||
authorPhone = metadata?.authorPhone,
|
||||
authorWebSite = metadata?.authorWebsite,
|
||||
bitcoin = metadata?.bitcoin,
|
||||
categories = metadata?.categories ?: emptyList(),
|
||||
changelog = metadata?.changelog,
|
||||
donate = metadata?.donate ?: emptyList(),
|
||||
featureGraphic = metadata?.featureGraphic,
|
||||
flattrID = metadata?.flattrID,
|
||||
issueTracker = metadata?.issueTracker,
|
||||
liberapay = metadata?.liberapay,
|
||||
license = metadata?.license,
|
||||
litecoin = metadata?.litecoin,
|
||||
openCollective = metadata?.openCollective,
|
||||
preferredSigner = metadata?.preferredSigner,
|
||||
promoGraphic = metadata?.promoGraphic,
|
||||
sourceCode = metadata?.sourceCode,
|
||||
screenshots = metadata?.screenshots,
|
||||
tvBanner = metadata?.tvBanner,
|
||||
translation = metadata?.translation,
|
||||
video = metadata?.video,
|
||||
webSite = metadata?.webSite,
|
||||
),
|
||||
versions = versions
|
||||
?.mapNotNull { (key, value) -> value?.let { key to it.toVersion() } }
|
||||
?.toMap() ?: emptyMap()
|
||||
)
|
||||
|
||||
fun patchInto(pack: PackageV2): PackageV2 {
|
||||
val versionsToRemove = versions?.filterValues { it == null }?.keys ?: emptySet()
|
||||
val versionsToAdd = versions
|
||||
?.mapNotNull { (key, value) ->
|
||||
value?.let { value ->
|
||||
if (pack.versions.keys.contains(key))
|
||||
pack.versions[key]?.let { value.patchInto(it) }
|
||||
else value.toVersion()
|
||||
}?.let { key to it }
|
||||
} ?: emptyList()
|
||||
|
||||
return pack.copy(
|
||||
metadata = pack.metadata.copy(
|
||||
added = pack.metadata.added,
|
||||
lastUpdated = metadata?.lastUpdated ?: pack.metadata.lastUpdated,
|
||||
name = metadata?.name
|
||||
?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap()
|
||||
?: pack.metadata.name,
|
||||
summary = metadata?.summary
|
||||
?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap()
|
||||
?: pack.metadata.summary,
|
||||
description = metadata?.description
|
||||
?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap()
|
||||
?: pack.metadata.description,
|
||||
icon = metadata?.icon ?: pack.metadata.icon,
|
||||
authorEmail = metadata?.authorEmail ?: pack.metadata.authorEmail,
|
||||
authorName = metadata?.authorName ?: pack.metadata.authorName,
|
||||
authorPhone = metadata?.authorPhone ?: pack.metadata.authorPhone,
|
||||
authorWebSite = metadata?.authorWebsite ?: pack.metadata.authorWebSite,
|
||||
bitcoin = metadata?.bitcoin ?: pack.metadata.bitcoin,
|
||||
categories = metadata?.categories ?: pack.metadata.categories,
|
||||
changelog = metadata?.changelog ?: pack.metadata.changelog,
|
||||
donate = metadata?.donate?.takeIf { it.isNotEmpty() } ?: pack.metadata.donate,
|
||||
featureGraphic = metadata?.featureGraphic ?: pack.metadata.featureGraphic,
|
||||
flattrID = metadata?.flattrID ?: pack.metadata.flattrID,
|
||||
issueTracker = metadata?.issueTracker ?: pack.metadata.issueTracker,
|
||||
liberapay = metadata?.liberapay ?: pack.metadata.liberapay,
|
||||
license = metadata?.license ?: pack.metadata.license,
|
||||
litecoin = metadata?.litecoin ?: pack.metadata.litecoin,
|
||||
openCollective = metadata?.openCollective ?: pack.metadata.openCollective,
|
||||
preferredSigner = metadata?.preferredSigner ?: pack.metadata.preferredSigner,
|
||||
promoGraphic = metadata?.promoGraphic ?: pack.metadata.promoGraphic,
|
||||
sourceCode = metadata?.sourceCode ?: pack.metadata.sourceCode,
|
||||
screenshots = metadata?.screenshots ?: pack.metadata.screenshots,
|
||||
tvBanner = metadata?.tvBanner ?: pack.metadata.tvBanner,
|
||||
translation = metadata?.translation ?: pack.metadata.translation,
|
||||
video = metadata?.video ?: pack.metadata.video,
|
||||
webSite = metadata?.webSite ?: pack.metadata.webSite,
|
||||
),
|
||||
versions = pack.versions
|
||||
.minus(versionsToRemove)
|
||||
.plus(versionsToAdd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MetadataV2(
|
||||
val name: LocalizedString? = null,
|
||||
val summary: LocalizedString? = null,
|
||||
val description: LocalizedString? = null,
|
||||
val icon: LocalizedIcon? = null,
|
||||
val added: Long,
|
||||
val lastUpdated: Long,
|
||||
val authorEmail: String? = null,
|
||||
val authorName: String? = null,
|
||||
val authorPhone: String? = null,
|
||||
val authorWebSite: String? = null,
|
||||
val bitcoin: String? = null,
|
||||
val categories: List<String> = emptyList(),
|
||||
val changelog: String? = null,
|
||||
val donate: List<String> = emptyList(),
|
||||
val featureGraphic: LocalizedIcon? = null,
|
||||
val flattrID: String? = null,
|
||||
val issueTracker: String? = null,
|
||||
val liberapay: String? = null,
|
||||
val license: String? = null,
|
||||
val litecoin: String? = null,
|
||||
val openCollective: String? = null,
|
||||
val preferredSigner: String? = null,
|
||||
val promoGraphic: LocalizedIcon? = null,
|
||||
val sourceCode: String? = null,
|
||||
val screenshots: ScreenshotsV2? = null,
|
||||
val tvBanner: LocalizedIcon? = null,
|
||||
val translation: String? = null,
|
||||
val video: LocalizedString? = null,
|
||||
val webSite: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MetadataV2Diff(
|
||||
val name: NullableLocalizedString? = null,
|
||||
val summary: NullableLocalizedString? = null,
|
||||
val description: NullableLocalizedString? = null,
|
||||
val icon: LocalizedIcon? = null,
|
||||
val added: Long? = null,
|
||||
val lastUpdated: Long? = null,
|
||||
val authorEmail: String? = null,
|
||||
val authorName: String? = null,
|
||||
val authorPhone: String? = null,
|
||||
val authorWebsite: String? = null,
|
||||
val bitcoin: String? = null,
|
||||
val categories: List<String> = emptyList(),
|
||||
val changelog: String? = null,
|
||||
val donate: List<String> = emptyList(),
|
||||
val featureGraphic: LocalizedIcon? = null,
|
||||
val flattrID: String? = null,
|
||||
val issueTracker: String? = null,
|
||||
val liberapay: String? = null,
|
||||
val license: String? = null,
|
||||
val litecoin: String? = null,
|
||||
val openCollective: String? = null,
|
||||
val preferredSigner: String? = null,
|
||||
val promoGraphic: LocalizedIcon? = null,
|
||||
val sourceCode: String? = null,
|
||||
val screenshots: ScreenshotsV2? = null,
|
||||
val tvBanner: LocalizedIcon? = null,
|
||||
val translation: String? = null,
|
||||
val video: LocalizedString? = null,
|
||||
val webSite: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VersionV2(
|
||||
val added: Long,
|
||||
val file: FileV2,
|
||||
val src: FileV2? = null,
|
||||
val whatsNew: LocalizedString = emptyMap(),
|
||||
val manifest: ManifestV2,
|
||||
val antiFeatures: Map<String, LocalizedString> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VersionV2Diff(
|
||||
val added: Long? = null,
|
||||
val file: FileV2? = null,
|
||||
val src: FileV2? = null,
|
||||
val whatsNew: LocalizedString? = null,
|
||||
val manifest: ManifestV2? = null,
|
||||
val antiFeatures: Map<String, LocalizedString>? = null,
|
||||
) {
|
||||
fun toVersion() = VersionV2(
|
||||
added = added ?: 0,
|
||||
file = file ?: FileV2(""),
|
||||
src = src ?: FileV2(""),
|
||||
whatsNew = whatsNew ?: emptyMap(),
|
||||
manifest = manifest ?: ManifestV2(
|
||||
versionName = "",
|
||||
versionCode = 0,
|
||||
),
|
||||
antiFeatures = antiFeatures ?: emptyMap(),
|
||||
)
|
||||
|
||||
fun patchInto(version: VersionV2): VersionV2 {
|
||||
return version.copy(
|
||||
added = added ?: version.added,
|
||||
file = file ?: version.file,
|
||||
src = src ?: version.src,
|
||||
whatsNew = whatsNew ?: version.whatsNew,
|
||||
manifest = manifest ?: version.manifest,
|
||||
antiFeatures = antiFeatures ?: version.antiFeatures,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ManifestV2(
|
||||
val versionName: String,
|
||||
val versionCode: Long,
|
||||
val signer: SignerV2? = null,
|
||||
val usesSdk: UsesSdkV2? = null,
|
||||
val maxSdkVersion: Int? = null,
|
||||
val usesPermission: List<PermissionV2> = emptyList(),
|
||||
val usesPermissionSdk23: List<PermissionV2> = emptyList(),
|
||||
val features: List<FeatureV2> = emptyList(),
|
||||
val nativecode: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UsesSdkV2(
|
||||
val minSdkVersion: Int,
|
||||
val targetSdkVersion: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PermissionV2(
|
||||
val name: String,
|
||||
val maxSdkVersion: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FeatureV2(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SignerV2(
|
||||
val sha256: List<String>,
|
||||
val hasMultipleSigners: Boolean = false,
|
||||
)
|
||||
|
||||
|
||||
@Serializable
|
||||
data class ScreenshotsV2(
|
||||
val phone: LocalizedFiles? = null,
|
||||
val sevenInch: LocalizedFiles? = null,
|
||||
val tenInch: LocalizedFiles? = null,
|
||||
val wear: LocalizedFiles? = null,
|
||||
val tv: LocalizedFiles? = null,
|
||||
) {
|
||||
|
||||
val isNull: Boolean =
|
||||
phone == null && sevenInch == null && tenInch == null && wear == null && tv == null
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.looker.droidify.sync.v2.model
|
||||
/*
|
||||
* RepoV2, AntiFeatureV2, CategoryV2, MirrorV2 are licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class RepoV2(
|
||||
val address: String,
|
||||
val icon: LocalizedIcon? = null,
|
||||
val name: LocalizedString = emptyMap(),
|
||||
val description: LocalizedString = emptyMap(),
|
||||
val antiFeatures: Map<String, AntiFeatureV2> = emptyMap(),
|
||||
val categories: Map<String, CategoryV2> = emptyMap(),
|
||||
val mirrors: List<MirrorV2> = emptyList(),
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RepoV2Diff(
|
||||
val address: String? = null,
|
||||
val icon: LocalizedIcon? = null,
|
||||
val name: LocalizedString? = null,
|
||||
val description: LocalizedString? = null,
|
||||
val antiFeatures: Map<String, AntiFeatureV2?>? = null,
|
||||
val categories: Map<String, CategoryV2?>? = null,
|
||||
val mirrors: List<MirrorV2>? = null,
|
||||
val timestamp: Long,
|
||||
) {
|
||||
fun patchInto(repo: RepoV2): RepoV2 {
|
||||
val (antiFeaturesToRemove, antiFeaturesToAdd) = (antiFeatures?.entries
|
||||
?.partition { it.value == null }
|
||||
?: Pair(emptyList(), emptyList()))
|
||||
.let {
|
||||
Pair(
|
||||
it.first.map { entry -> entry.key }.toSet(),
|
||||
it.second.mapNotNull { (key, value) -> value?.let { key to value } }
|
||||
)
|
||||
}
|
||||
|
||||
val (categoriesToRemove, categoriesToAdd) = (categories?.entries
|
||||
?.partition { it.value == null }
|
||||
?: Pair(emptyList(), emptyList()))
|
||||
.let {
|
||||
Pair(
|
||||
it.first.map { entry -> entry.key }.toSet(),
|
||||
it.second.mapNotNull { (key, value) -> value?.let { key to value } }
|
||||
)
|
||||
}
|
||||
|
||||
return repo.copy(
|
||||
timestamp = timestamp,
|
||||
address = address ?: repo.address,
|
||||
icon = icon ?: repo.icon,
|
||||
name = name ?: repo.name,
|
||||
description = description ?: repo.description,
|
||||
mirrors = mirrors ?: repo.mirrors,
|
||||
antiFeatures = repo.antiFeatures
|
||||
.minus(antiFeaturesToRemove)
|
||||
.plus(antiFeaturesToAdd),
|
||||
categories = repo.categories
|
||||
.minus(categoriesToRemove)
|
||||
.plus(categoriesToAdd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MirrorV2(
|
||||
val url: String,
|
||||
val isPrimary: Boolean? = null,
|
||||
val location: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CategoryV2(
|
||||
val icon: LocalizedIcon = emptyMap(),
|
||||
val name: LocalizedString,
|
||||
val description: LocalizedString = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AntiFeatureV2(
|
||||
val icon: LocalizedIcon = emptyMap(),
|
||||
val name: LocalizedString,
|
||||
val description: LocalizedString = emptyMap(),
|
||||
)
|
||||
274
app/src/main/kotlin/com/looker/droidify/ui/MessageDialog.kt
Normal file
274
app/src/main/kotlin/com/looker/droidify/ui/MessageDialog.kt
Normal file
@@ -0,0 +1,274 @@
|
||||
package com.looker.droidify.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.nullIfEmpty
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.ui.repository.RepositoryFragment
|
||||
import com.looker.droidify.utility.PackageItemResolver
|
||||
import com.looker.droidify.utility.extension.android.Android
|
||||
import kotlinx.parcelize.Parceler
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.TypeParceler
|
||||
import com.looker.droidify.R.string as stringRes
|
||||
|
||||
class MessageDialog() : DialogFragment() {
|
||||
companion object {
|
||||
private const val EXTRA_MESSAGE = "message"
|
||||
}
|
||||
|
||||
constructor(message: Message) : this() {
|
||||
arguments = bundleOf(EXTRA_MESSAGE to message)
|
||||
}
|
||||
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
show(fragmentManager, this::class.java.name)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
|
||||
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||
val message = if (SdkCheck.isTiramisu) {
|
||||
arguments?.getParcelable(EXTRA_MESSAGE, Message::class.java)!!
|
||||
} else {
|
||||
arguments?.getParcelable(EXTRA_MESSAGE)!!
|
||||
}
|
||||
when (message) {
|
||||
is Message.DeleteRepositoryConfirm -> {
|
||||
dialog.setTitle(stringRes.confirmation)
|
||||
dialog.setMessage(stringRes.delete_repository_DESC)
|
||||
dialog.setPositiveButton(stringRes.delete) { _, _ ->
|
||||
(parentFragment as RepositoryFragment).onDeleteConfirm()
|
||||
}
|
||||
dialog.setNegativeButton(stringRes.cancel, null)
|
||||
}
|
||||
|
||||
is Message.CantEditSyncing -> {
|
||||
dialog.setTitle(stringRes.action_failed)
|
||||
dialog.setMessage(stringRes.cant_edit_sync_DESC)
|
||||
dialog.setPositiveButton(stringRes.ok, null)
|
||||
}
|
||||
|
||||
is Message.Link -> {
|
||||
dialog.setTitle(stringRes.confirmation)
|
||||
dialog.setMessage(getString(stringRes.open_DESC_FORMAT, message.uri.toString()))
|
||||
dialog.setPositiveButton(stringRes.ok) { _, _ ->
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, message.uri))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
dialog.setNegativeButton(stringRes.cancel, null)
|
||||
}
|
||||
|
||||
is Message.Permissions -> {
|
||||
val packageManager = requireContext().packageManager
|
||||
val builder = StringBuilder()
|
||||
val localCache = PackageItemResolver.LocalCache()
|
||||
val title = if (message.group != null) {
|
||||
val name = try {
|
||||
val permissionGroupInfo =
|
||||
packageManager.getPermissionGroupInfo(message.group, 0)
|
||||
PackageItemResolver.loadLabel(
|
||||
requireContext(),
|
||||
localCache,
|
||||
permissionGroupInfo
|
||||
)?.nullIfEmpty()?.let { if (it == message.group) null else it }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
name ?: getString(stringRes.unknown)
|
||||
} else {
|
||||
getString(stringRes.other)
|
||||
}
|
||||
for (permission in message.permissions) {
|
||||
kotlin.runCatching {
|
||||
val permissionInfo = packageManager.getPermissionInfo(permission, 0)
|
||||
PackageItemResolver.loadDescription(
|
||||
requireContext(),
|
||||
localCache,
|
||||
permissionInfo
|
||||
)?.nullIfEmpty()?.let { if (it == permission) null else it }
|
||||
?: error("Invalid Permission Description")
|
||||
}.onSuccess {
|
||||
builder.append(it).append("\n\n")
|
||||
}
|
||||
}
|
||||
if (builder.isNotEmpty()) {
|
||||
builder.delete(builder.length - 2, builder.length)
|
||||
} else {
|
||||
builder.append(getString(stringRes.no_description_available_DESC))
|
||||
}
|
||||
dialog.setTitle(title)
|
||||
dialog.setMessage(builder)
|
||||
dialog.setPositiveButton(stringRes.ok, null)
|
||||
}
|
||||
|
||||
is Message.ReleaseIncompatible -> {
|
||||
val builder = StringBuilder()
|
||||
val minSdkVersion =
|
||||
if (Release.Incompatibility.MinSdk in message.incompatibilities) {
|
||||
message.minSdkVersion
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val maxSdkVersion =
|
||||
if (Release.Incompatibility.MaxSdk in message.incompatibilities) {
|
||||
message.maxSdkVersion
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (minSdkVersion != null || maxSdkVersion != null) {
|
||||
val versionMessage = minSdkVersion?.let {
|
||||
getString(
|
||||
stringRes.incompatible_api_min_DESC_FORMAT,
|
||||
it
|
||||
)
|
||||
}
|
||||
?: maxSdkVersion?.let {
|
||||
getString(
|
||||
stringRes.incompatible_api_max_DESC_FORMAT,
|
||||
it
|
||||
)
|
||||
}
|
||||
builder.append(
|
||||
getString(
|
||||
stringRes.incompatible_api_DESC_FORMAT,
|
||||
Android.name,
|
||||
SdkCheck.sdk,
|
||||
versionMessage.orEmpty()
|
||||
)
|
||||
).append("\n\n")
|
||||
}
|
||||
if (Release.Incompatibility.Platform in message.incompatibilities) {
|
||||
builder.append(
|
||||
getString(
|
||||
stringRes.incompatible_platforms_DESC_FORMAT,
|
||||
Android.primaryPlatform ?: getString(stringRes.unknown),
|
||||
message.platforms.joinToString(separator = ", ")
|
||||
)
|
||||
).append("\n\n")
|
||||
}
|
||||
val features =
|
||||
message.incompatibilities.mapNotNull { it as? Release.Incompatibility.Feature }
|
||||
if (features.isNotEmpty()) {
|
||||
builder.append(getString(stringRes.incompatible_features_DESC))
|
||||
for (feature in features) {
|
||||
builder.append("\n\u2022 ").append(feature.feature)
|
||||
}
|
||||
builder.append("\n\n")
|
||||
}
|
||||
if (builder.isNotEmpty()) {
|
||||
builder.delete(builder.length - 2, builder.length)
|
||||
}
|
||||
dialog.setTitle(stringRes.incompatible_version)
|
||||
dialog.setMessage(builder)
|
||||
dialog.setPositiveButton(stringRes.ok, null)
|
||||
}
|
||||
|
||||
is Message.ReleaseOlder -> {
|
||||
dialog.setTitle(stringRes.incompatible_version)
|
||||
dialog.setMessage(stringRes.incompatible_older_DESC)
|
||||
dialog.setPositiveButton(stringRes.ok, null)
|
||||
}
|
||||
|
||||
is Message.ReleaseSignatureMismatch -> {
|
||||
dialog.setTitle(stringRes.incompatible_version)
|
||||
dialog.setMessage(stringRes.incompatible_signature_DESC)
|
||||
dialog.setPositiveButton(stringRes.ok, null)
|
||||
}
|
||||
|
||||
is Message.InsufficientStorage -> {
|
||||
dialog.setTitle(stringRes.insufficient_storage)
|
||||
dialog.setMessage(stringRes.insufficient_storage_DESC)
|
||||
dialog.setPositiveButton(stringRes.ok, null)
|
||||
}
|
||||
}::class
|
||||
return dialog.create()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface Message : Parcelable {
|
||||
@Parcelize
|
||||
data object DeleteRepositoryConfirm : Message
|
||||
|
||||
@Parcelize
|
||||
data object CantEditSyncing : Message
|
||||
|
||||
@Parcelize
|
||||
class Link(val uri: Uri) : Message
|
||||
|
||||
@Parcelize
|
||||
class Permissions(val group: String?, val permissions: List<String>) : Message
|
||||
|
||||
@Parcelize
|
||||
@TypeParceler<Release.Incompatibility, ReleaseIncompatibilityParceler>
|
||||
class ReleaseIncompatible(
|
||||
val incompatibilities: List<Release.Incompatibility>,
|
||||
val platforms: List<String>,
|
||||
val minSdkVersion: Int,
|
||||
val maxSdkVersion: Int
|
||||
) : Message
|
||||
|
||||
@Parcelize
|
||||
data object ReleaseOlder : Message
|
||||
|
||||
@Parcelize
|
||||
data object ReleaseSignatureMismatch : Message
|
||||
|
||||
@Parcelize
|
||||
data object InsufficientStorage : Message
|
||||
}
|
||||
|
||||
class ReleaseIncompatibilityParceler : Parceler<Release.Incompatibility> {
|
||||
|
||||
private companion object {
|
||||
// Incompatibility indices in `Parcel`
|
||||
const val MIN_SDK_INDEX = 0
|
||||
const val MAX_SDK_INDEX = 1
|
||||
const val PLATFORM_INDEX = 2
|
||||
const val FEATURE_INDEX = 3
|
||||
}
|
||||
|
||||
override fun create(parcel: Parcel): Release.Incompatibility {
|
||||
return when (parcel.readInt()) {
|
||||
MIN_SDK_INDEX -> Release.Incompatibility.MinSdk
|
||||
MAX_SDK_INDEX -> Release.Incompatibility.MaxSdk
|
||||
PLATFORM_INDEX -> Release.Incompatibility.Platform
|
||||
FEATURE_INDEX -> Release.Incompatibility.Feature(requireNotNull(parcel.readString()))
|
||||
else -> error("Invalid Index for Incompatibility")
|
||||
}
|
||||
}
|
||||
|
||||
override fun Release.Incompatibility.write(parcel: Parcel, flags: Int) {
|
||||
when (this) {
|
||||
is Release.Incompatibility.MinSdk -> {
|
||||
parcel.writeInt(MIN_SDK_INDEX)
|
||||
}
|
||||
|
||||
is Release.Incompatibility.MaxSdk -> {
|
||||
parcel.writeInt(MAX_SDK_INDEX)
|
||||
}
|
||||
|
||||
is Release.Incompatibility.Platform -> {
|
||||
parcel.writeInt(PLATFORM_INDEX)
|
||||
}
|
||||
|
||||
is Release.Incompatibility.Feature -> {
|
||||
parcel.writeInt(FEATURE_INDEX)
|
||||
parcel.writeString(feature)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/src/main/kotlin/com/looker/droidify/ui/ScreenFragment.kt
Normal file
31
app/src/main/kotlin/com/looker/droidify/ui/ScreenFragment.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.looker.droidify.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.looker.droidify.databinding.FragmentBinding
|
||||
|
||||
open class ScreenFragment : Fragment() {
|
||||
private var _fragmentBinding: FragmentBinding? = null
|
||||
val fragmentBinding get() = _fragmentBinding!!
|
||||
val toolbar: MaterialToolbar get() = fragmentBinding.toolbar
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
_fragmentBinding = FragmentBinding.inflate(layoutInflater)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View = fragmentBinding.root
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_fragmentBinding = null
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,577 @@
|
||||
package com.looker.droidify.ui.appDetail
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import coil3.load
|
||||
import coil3.request.allowHardware
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.looker.droidify.content.ProductPreferences
|
||||
import com.looker.droidify.installer.installers.launchShizuku
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.installer.model.isCancellable
|
||||
import com.looker.droidify.model.InstalledItem
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.ProductPreference
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.model.findSuggested
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.DownloadService
|
||||
import com.looker.droidify.ui.Message
|
||||
import com.looker.droidify.ui.MessageDialog
|
||||
import com.looker.droidify.ui.ScreenFragment
|
||||
import com.looker.droidify.ui.appDetail.AppDetailViewModel.Companion.ARG_PACKAGE_NAME
|
||||
import com.looker.droidify.ui.appDetail.AppDetailViewModel.Companion.ARG_REPO_ADDRESS
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.extension.getLauncherActivities
|
||||
import com.looker.droidify.utility.common.extension.getMutatedIcon
|
||||
import com.looker.droidify.utility.common.extension.isFirstItemVisible
|
||||
import com.looker.droidify.utility.common.extension.isSystemApplication
|
||||
import com.looker.droidify.utility.common.extension.systemBarsPadding
|
||||
import com.looker.droidify.utility.common.extension.updateAsMutable
|
||||
import com.looker.droidify.utility.extension.mainActivity
|
||||
import com.looker.droidify.utility.extension.startUpdate
|
||||
import com.stfalcon.imageviewer.StfalconImageViewer
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import com.looker.droidify.R.string as stringRes
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
|
||||
companion object {
|
||||
private const val STATE_LAYOUT_MANAGER = "layoutManager"
|
||||
private const val STATE_ADAPTER = "adapter"
|
||||
}
|
||||
|
||||
constructor(packageName: String, repoAddress: String? = null) : this() {
|
||||
arguments = bundleOf(
|
||||
ARG_PACKAGE_NAME to packageName,
|
||||
ARG_REPO_ADDRESS to repoAddress
|
||||
)
|
||||
}
|
||||
|
||||
private enum class Action(
|
||||
val id: Int,
|
||||
val adapterAction: AppDetailAdapter.Action
|
||||
) {
|
||||
INSTALL(1, AppDetailAdapter.Action.INSTALL),
|
||||
UPDATE(2, AppDetailAdapter.Action.UPDATE),
|
||||
LAUNCH(3, AppDetailAdapter.Action.LAUNCH),
|
||||
DETAILS(4, AppDetailAdapter.Action.DETAILS),
|
||||
UNINSTALL(5, AppDetailAdapter.Action.UNINSTALL),
|
||||
SHARE(6, AppDetailAdapter.Action.SHARE)
|
||||
}
|
||||
|
||||
private class Installed(
|
||||
val installedItem: InstalledItem,
|
||||
val isSystem: Boolean,
|
||||
val launcherActivities: List<Pair<String, String>>
|
||||
)
|
||||
|
||||
private val viewModel: AppDetailViewModel by viewModels()
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private var layoutManagerState: LinearLayoutManager.SavedState? = null
|
||||
|
||||
private var actions = Pair(emptySet<Action>(), null as Action?)
|
||||
private var products = emptyList<Pair<Product, Repository>>()
|
||||
private var installed: Installed? = null
|
||||
private var downloading = false
|
||||
private var installing: InstallState? = null
|
||||
|
||||
private var recyclerView: RecyclerView? = null
|
||||
private var detailAdapter: AppDetailAdapter? = null
|
||||
private var imageViewer: StfalconImageViewer.Builder<Product.Screenshot>? = null
|
||||
|
||||
private val downloadConnection = Connection(
|
||||
serviceClass = DownloadService::class.java,
|
||||
onBind = { _, binder ->
|
||||
lifecycleScope.launch {
|
||||
binder.downloadState.collect(::updateDownloadState)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
detailAdapter = AppDetailAdapter(this@AppDetailFragment)
|
||||
mainActivity.onToolbarCreated(toolbar)
|
||||
toolbar.menu.apply {
|
||||
Action.entries.forEach { action ->
|
||||
add(0, action.id, 0, action.adapterAction.titleResId)
|
||||
.setIcon(toolbar.context.getMutatedIcon(action.adapterAction.iconResId))
|
||||
.setVisible(false)
|
||||
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
.setOnMenuItemClickListener {
|
||||
onActionClick(action.adapterAction)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val content = fragmentBinding.fragmentContent
|
||||
content.addView(
|
||||
RecyclerView(content.context).apply {
|
||||
id = android.R.id.list
|
||||
this.layoutManager = LinearLayoutManager(
|
||||
context,
|
||||
LinearLayoutManager.VERTICAL,
|
||||
false
|
||||
)
|
||||
isMotionEventSplittingEnabled = false
|
||||
isVerticalScrollBarEnabled = false
|
||||
adapter = detailAdapter
|
||||
(itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
if (detailAdapter != null) {
|
||||
savedInstanceState?.getParcelable<AppDetailAdapter.SavedState>(STATE_ADAPTER)
|
||||
?.let(detailAdapter!!::restoreState)
|
||||
}
|
||||
layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER)
|
||||
recyclerView = this
|
||||
systemBarsPadding(includeFab = false)
|
||||
}
|
||||
)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
launch {
|
||||
viewModel.state.collectLatest { state ->
|
||||
products = state.products.mapNotNull { product ->
|
||||
val requiredRepo = state.repos.find { it.id == product.repositoryId }
|
||||
requiredRepo?.let { product to it }
|
||||
}
|
||||
layoutManagerState?.let {
|
||||
recyclerView?.layoutManager!!.onRestoreInstanceState(it)
|
||||
}
|
||||
layoutManagerState = null
|
||||
installed = state.installedItem?.let {
|
||||
with(requireContext().packageManager) {
|
||||
val isSystem = isSystemApplication(viewModel.packageName)
|
||||
val launcherActivities = if (state.isSelf) {
|
||||
emptyList()
|
||||
} else {
|
||||
getLauncherActivities(viewModel.packageName)
|
||||
}
|
||||
Installed(it, isSystem, launcherActivities)
|
||||
}
|
||||
}
|
||||
val adapter = recyclerView?.adapter as? AppDetailAdapter
|
||||
|
||||
// `delay` is cancellable hence it waits for 50 milliseconds to show empty page
|
||||
if (products.isEmpty()) delay(50)
|
||||
|
||||
adapter?.setProducts(
|
||||
context = requireContext(),
|
||||
packageName = viewModel.packageName,
|
||||
suggestedRepo = state.addressIfUnavailable,
|
||||
products = products,
|
||||
installedItem = state.installedItem,
|
||||
isFavourite = state.isFavourite,
|
||||
allowIncompatibleVersion = state.allowIncompatibleVersions
|
||||
)
|
||||
updateButtons()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
viewModel.installerState.collect(::updateInstallState)
|
||||
}
|
||||
launch {
|
||||
recyclerView?.isFirstItemVisible?.collect(::updateToolbarButtons)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
downloadConnection.bind(requireContext())
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
recyclerView = null
|
||||
detailAdapter = null
|
||||
imageViewer = null
|
||||
|
||||
downloadConnection.unbind(requireContext())
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
val layoutManagerState =
|
||||
layoutManagerState ?: recyclerView?.layoutManager?.onSaveInstanceState()
|
||||
layoutManagerState?.let { outState.putParcelable(STATE_LAYOUT_MANAGER, it) }
|
||||
val adapterState = (recyclerView?.adapter as? AppDetailAdapter)?.saveState()
|
||||
adapterState?.let { outState.putParcelable(STATE_ADAPTER, it) }
|
||||
}
|
||||
|
||||
private fun updateButtons(
|
||||
preference: ProductPreference = ProductPreferences[viewModel.packageName]
|
||||
) {
|
||||
val installed = installed
|
||||
val product = products.findSuggested(installed?.installedItem)?.first
|
||||
val compatible = product != null && product.selectedReleases.firstOrNull()
|
||||
.let { it != null && it.incompatibilities.isEmpty() }
|
||||
val canInstall = product != null && installed == null && compatible
|
||||
val canUpdate =
|
||||
product != null && compatible && product.canUpdate(installed?.installedItem) &&
|
||||
!preference.shouldIgnoreUpdate(product.versionCode)
|
||||
val canUninstall = product != null && installed != null && !installed.isSystem
|
||||
val canLaunch =
|
||||
product != null && installed != null && installed.launcherActivities.isNotEmpty()
|
||||
|
||||
val actions = buildSet {
|
||||
if (canInstall) add(Action.INSTALL)
|
||||
if (canUpdate) add(Action.UPDATE)
|
||||
if (canLaunch) add(Action.LAUNCH)
|
||||
if (installed != null) add(Action.DETAILS)
|
||||
if (canUninstall) add(Action.UNINSTALL)
|
||||
add(Action.SHARE)
|
||||
}
|
||||
|
||||
val primaryAction = when {
|
||||
canUpdate -> Action.UPDATE
|
||||
canLaunch -> Action.LAUNCH
|
||||
canInstall -> Action.INSTALL
|
||||
installed != null -> Action.DETAILS
|
||||
else -> Action.SHARE
|
||||
}
|
||||
|
||||
val adapterAction = when {
|
||||
installing == InstallState.Installing -> null
|
||||
installing == InstallState.Pending -> AppDetailAdapter.Action.CANCEL
|
||||
downloading -> AppDetailAdapter.Action.CANCEL
|
||||
else -> primaryAction.adapterAction
|
||||
}
|
||||
|
||||
(recyclerView?.adapter as? AppDetailAdapter)?.action = adapterAction
|
||||
|
||||
for (action in sequenceOf(
|
||||
Action.INSTALL,
|
||||
Action.UPDATE,
|
||||
)) {
|
||||
toolbar.menu.findItem(action.id).isEnabled = !downloading
|
||||
}
|
||||
this.actions = Pair(actions, primaryAction)
|
||||
updateToolbarButtons()
|
||||
}
|
||||
|
||||
private fun updateToolbarButtons(
|
||||
isActionVisible: Boolean = (recyclerView?.layoutManager as LinearLayoutManager)
|
||||
.findFirstVisibleItemPosition() == 0
|
||||
) {
|
||||
toolbar.title = if (isActionVisible) {
|
||||
getString(stringRes.application)
|
||||
} else {
|
||||
products.firstOrNull()?.first?.name ?: getString(stringRes.application)
|
||||
}
|
||||
val (actions, primaryAction) = actions
|
||||
val displayActions = actions.updateAsMutable {
|
||||
if (isActionVisible && primaryAction != null) {
|
||||
remove(primaryAction)
|
||||
}
|
||||
if (size >= 4 && resources.configuration.screenWidthDp < 400) {
|
||||
remove(Action.DETAILS)
|
||||
}
|
||||
}
|
||||
Action.entries.forEach { action ->
|
||||
toolbar.menu.findItem(action.id).isVisible = action in displayActions
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateInstallState(installerState: InstallState?) {
|
||||
val status = when (installerState) {
|
||||
InstallState.Pending -> AppDetailAdapter.Status.PendingInstall
|
||||
InstallState.Installing -> AppDetailAdapter.Status.Installing
|
||||
else -> AppDetailAdapter.Status.Idle
|
||||
}
|
||||
(recyclerView?.adapter as? AppDetailAdapter)?.status = status
|
||||
installing = installerState
|
||||
updateButtons()
|
||||
}
|
||||
|
||||
private fun updateDownloadState(state: DownloadService.DownloadState) {
|
||||
val packageName = viewModel.packageName
|
||||
val isPending = packageName in state.queue
|
||||
val isDownloading = state isDownloading packageName
|
||||
val isCompleted = state isComplete packageName
|
||||
val isActive = isPending || isDownloading
|
||||
if (isPending) {
|
||||
detailAdapter?.status = AppDetailAdapter.Status.Pending
|
||||
}
|
||||
if (isDownloading) {
|
||||
detailAdapter?.status = when (state.currentItem) {
|
||||
is DownloadService.State.Connecting -> AppDetailAdapter.Status.Connecting
|
||||
is DownloadService.State.Downloading -> AppDetailAdapter.Status.Downloading(
|
||||
state.currentItem.read,
|
||||
state.currentItem.total
|
||||
)
|
||||
|
||||
else -> AppDetailAdapter.Status.Idle
|
||||
}
|
||||
}
|
||||
if (isCompleted) {
|
||||
detailAdapter?.status = AppDetailAdapter.Status.Idle
|
||||
}
|
||||
if (this.downloading != isActive) {
|
||||
this.downloading = isActive
|
||||
updateButtons()
|
||||
}
|
||||
if (state.currentItem is DownloadService.State.Success && isResumed) {
|
||||
viewModel.installPackage(
|
||||
state.currentItem.packageName,
|
||||
state.currentItem.release.cacheFileName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActionClick(action: AppDetailAdapter.Action) {
|
||||
when (action) {
|
||||
AppDetailAdapter.Action.INSTALL,
|
||||
AppDetailAdapter.Action.UPDATE -> {
|
||||
if (Cache.getEmptySpace(requireContext()) < products.first().first.releases.first().size) {
|
||||
MessageDialog(Message.InsufficientStorage).show(childFragmentManager)
|
||||
return
|
||||
}
|
||||
val shizukuState = viewModel.shizukuState(requireContext())
|
||||
if (shizukuState != null && shizukuState.check) {
|
||||
shizukuDialog(
|
||||
context = requireContext(),
|
||||
shizukuState = shizukuState,
|
||||
openShizuku = { launchShizuku(requireContext()) },
|
||||
switchInstaller = { viewModel.setDefaultInstaller() },
|
||||
).show()
|
||||
return
|
||||
}
|
||||
downloadConnection.startUpdate(
|
||||
packageName = viewModel.packageName,
|
||||
installedItem = installed?.installedItem,
|
||||
products = products,
|
||||
)
|
||||
}
|
||||
|
||||
AppDetailAdapter.Action.LAUNCH -> {
|
||||
val launcherActivities = installed?.launcherActivities.orEmpty()
|
||||
if (launcherActivities.size >= 2) {
|
||||
LaunchDialog(launcherActivities).show(
|
||||
childFragmentManager,
|
||||
LaunchDialog::class.java.name
|
||||
)
|
||||
} else {
|
||||
launcherActivities.firstOrNull()?.let { startLauncherActivity(it.first) }
|
||||
}
|
||||
}
|
||||
|
||||
AppDetailAdapter.Action.DETAILS -> {
|
||||
startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
"package:${viewModel.packageName}".toUri()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
AppDetailAdapter.Action.UNINSTALL -> viewModel.uninstallPackage()
|
||||
|
||||
AppDetailAdapter.Action.CANCEL -> {
|
||||
val binder = downloadConnection.binder
|
||||
if (installing?.isCancellable == true) {
|
||||
viewModel.removeQueue()
|
||||
} else if (downloading && binder != null) {
|
||||
binder.cancel(viewModel.packageName)
|
||||
}
|
||||
}
|
||||
|
||||
AppDetailAdapter.Action.SHARE -> {
|
||||
val repo = products[0].second
|
||||
val address = when {
|
||||
repo.name == "F-Droid" ->
|
||||
"https://f-droid.org/packages/" +
|
||||
"${viewModel.packageName}/"
|
||||
|
||||
"IzzyOnDroid" in repo.name -> {
|
||||
"https://apt.izzysoft.de/fdroid/index/apk/${viewModel.packageName}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"https://droidify.eu.org/app/?id=" +
|
||||
"${viewModel.packageName}&repo_address=${repo.address}"
|
||||
}
|
||||
}
|
||||
val sendIntent = Intent(Intent.ACTION_SEND)
|
||||
.putExtra(Intent.EXTRA_TEXT, address)
|
||||
.setType("text/plain")
|
||||
startActivity(Intent.createChooser(sendIntent, null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFavouriteClicked() {
|
||||
viewModel.setFavouriteState()
|
||||
}
|
||||
|
||||
private fun startLauncherActivity(name: String) {
|
||||
try {
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setComponent(ComponentName(viewModel.packageName, name))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceChanged(preference: ProductPreference) {
|
||||
updateButtons(preference)
|
||||
}
|
||||
|
||||
override fun onPermissionsClick(group: String?, permissions: List<String>) {
|
||||
MessageDialog(Message.Permissions(group, permissions))
|
||||
.show(childFragmentManager)
|
||||
}
|
||||
|
||||
override fun onScreenshotClick(position: Int) {
|
||||
if (imageViewer == null) {
|
||||
val productRepository = products.findSuggested(installed?.installedItem) ?: return
|
||||
val screenshots = productRepository.first.screenshots.mapNotNull {
|
||||
if (it.type == Product.Screenshot.Type.VIDEO) null
|
||||
else it
|
||||
}
|
||||
imageViewer = StfalconImageViewer
|
||||
.Builder(context, screenshots) { view, current ->
|
||||
val screenshotUrl = current.url(
|
||||
context = requireContext(),
|
||||
repository = productRepository.second,
|
||||
packageName = viewModel.packageName
|
||||
)
|
||||
view.load(screenshotUrl) {
|
||||
allowHardware(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
imageViewer?.withStartPosition(position)
|
||||
imageViewer?.show()
|
||||
}
|
||||
|
||||
override fun onReleaseClick(release: Release) {
|
||||
val installedItem = installed?.installedItem
|
||||
when {
|
||||
release.incompatibilities.isNotEmpty() -> {
|
||||
MessageDialog(
|
||||
Message.ReleaseIncompatible(
|
||||
release.incompatibilities,
|
||||
release.platforms,
|
||||
release.minSdkVersion,
|
||||
release.maxSdkVersion
|
||||
)
|
||||
).show(childFragmentManager)
|
||||
}
|
||||
|
||||
Cache.getEmptySpace(requireContext()) < release.size -> {
|
||||
MessageDialog(Message.InsufficientStorage).show(childFragmentManager)
|
||||
}
|
||||
|
||||
installedItem != null && installedItem.versionCode > release.versionCode -> {
|
||||
MessageDialog(Message.ReleaseOlder).show(childFragmentManager)
|
||||
}
|
||||
|
||||
installedItem != null && installedItem.signature != release.signature -> {
|
||||
lifecycleScope.launch {
|
||||
if (viewModel.shouldIgnoreSignature()) {
|
||||
queueReleaseInstall(release, installedItem)
|
||||
} else {
|
||||
MessageDialog(Message.ReleaseSignatureMismatch).show(childFragmentManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
queueReleaseInstall(release, installedItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueReleaseInstall(release: Release, installedItem: InstalledItem?) {
|
||||
val productRepository =
|
||||
products.asSequence().filter { (product, _) ->
|
||||
product.releases.any { it === release }
|
||||
}.firstOrNull()
|
||||
if (productRepository != null) {
|
||||
downloadConnection.binder?.enqueue(
|
||||
viewModel.packageName,
|
||||
productRepository.first.name,
|
||||
productRepository.second,
|
||||
release,
|
||||
installedItem != null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestAddRepository(address: String) {
|
||||
mainActivity.navigateAddRepository(address)
|
||||
}
|
||||
|
||||
override fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean {
|
||||
return if (shouldConfirm && (uri.scheme == "http" || uri.scheme == "https")) {
|
||||
MessageDialog(Message.Link(uri)).show(childFragmentManager)
|
||||
true
|
||||
} else {
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, uri))
|
||||
true
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LaunchDialog() : DialogFragment() {
|
||||
companion object {
|
||||
private const val EXTRA_NAMES = "names"
|
||||
private const val EXTRA_LABELS = "labels"
|
||||
}
|
||||
|
||||
constructor(launcherActivities: List<Pair<String, String>>) : this() {
|
||||
arguments = Bundle().apply {
|
||||
putStringArrayList(EXTRA_NAMES, ArrayList(launcherActivities.map { it.first }))
|
||||
putStringArrayList(EXTRA_LABELS, ArrayList(launcherActivities.map { it.second }))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
|
||||
val names = requireArguments().getStringArrayList(EXTRA_NAMES)!!
|
||||
val labels = requireArguments().getStringArrayList(EXTRA_LABELS)!!
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(stringRes.launch)
|
||||
.setItems(labels.toTypedArray()) { _, position ->
|
||||
(parentFragment as AppDetailFragment)
|
||||
.startLauncherActivity(names[position])
|
||||
}
|
||||
.setNegativeButton(stringRes.cancel, null)
|
||||
.create()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.looker.droidify.ui.appDetail
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.looker.droidify.utility.common.extension.asStateFlow
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.domain.model.toPackageName
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.model.InstalledItem
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.installer.InstallManager
|
||||
import com.looker.droidify.installer.installers.isShizukuAlive
|
||||
import com.looker.droidify.installer.installers.isShizukuGranted
|
||||
import com.looker.droidify.installer.installers.isShizukuInstalled
|
||||
import com.looker.droidify.installer.installers.requestPermissionListener
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.installer.model.installFrom
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AppDetailViewModel @Inject constructor(
|
||||
private val installer: InstallManager,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
val packageName: String = requireNotNull(savedStateHandle[ARG_PACKAGE_NAME])
|
||||
|
||||
private val repoAddress: StateFlow<String?> =
|
||||
savedStateHandle.getStateFlow(ARG_REPO_ADDRESS, null)
|
||||
|
||||
val installerState: StateFlow<InstallState?> =
|
||||
installer.state.mapNotNull { stateMap ->
|
||||
stateMap[packageName.toPackageName()]
|
||||
}.asStateFlow(null)
|
||||
|
||||
val state =
|
||||
combine(
|
||||
Database.ProductAdapter.getStream(packageName),
|
||||
Database.RepositoryAdapter.getAllStream(),
|
||||
Database.InstalledAdapter.getStream(packageName),
|
||||
repoAddress,
|
||||
flow { emit(settingsRepository.getInitial()) }
|
||||
) { products, repositories, installedItem, suggestedAddress, initialSettings ->
|
||||
val idAndRepos = repositories.associateBy { it.id }
|
||||
val filteredProducts = products.filter { product ->
|
||||
idAndRepos[product.repositoryId] != null
|
||||
}
|
||||
AppDetailUiState(
|
||||
products = filteredProducts,
|
||||
repos = repositories,
|
||||
installedItem = installedItem,
|
||||
isFavourite = packageName in initialSettings.favouriteApps,
|
||||
allowIncompatibleVersions = initialSettings.incompatibleVersions,
|
||||
isSelf = packageName == BuildConfig.APPLICATION_ID,
|
||||
addressIfUnavailable = suggestedAddress
|
||||
)
|
||||
}.asStateFlow(AppDetailUiState())
|
||||
|
||||
fun shizukuState(context: Context): ShizukuState? {
|
||||
val isSelected =
|
||||
runBlocking { settingsRepository.getInitial().installerType == InstallerType.SHIZUKU }
|
||||
if (!isSelected) return null
|
||||
val isAlive = isShizukuAlive()
|
||||
val isGranted = if (isAlive) {
|
||||
if (isShizukuGranted()) {
|
||||
true
|
||||
} else {
|
||||
runBlocking { requestPermissionListener() }
|
||||
}
|
||||
} else false
|
||||
return ShizukuState(
|
||||
isNotInstalled = !isShizukuInstalled(context),
|
||||
isNotGranted = !isGranted,
|
||||
isNotAlive = !isAlive,
|
||||
)
|
||||
}
|
||||
|
||||
fun setDefaultInstaller() {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setInstallerType(InstallerType.Default)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun shouldIgnoreSignature(): Boolean {
|
||||
return settingsRepository.getInitial().ignoreSignature
|
||||
}
|
||||
|
||||
fun setFavouriteState() {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.toggleFavourites(packageName)
|
||||
}
|
||||
}
|
||||
|
||||
fun installPackage(packageName: String, fileName: String) {
|
||||
viewModelScope.launch {
|
||||
installer install (packageName installFrom fileName)
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstallPackage() {
|
||||
viewModelScope.launch {
|
||||
installer uninstall packageName.toPackageName()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeQueue() {
|
||||
viewModelScope.launch {
|
||||
installer remove packageName.toPackageName()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ARG_PACKAGE_NAME = "package_name"
|
||||
const val ARG_REPO_ADDRESS = "repo_address"
|
||||
}
|
||||
}
|
||||
|
||||
data class ShizukuState(
|
||||
val isNotInstalled: Boolean,
|
||||
val isNotGranted: Boolean,
|
||||
val isNotAlive: Boolean,
|
||||
) {
|
||||
val check: Boolean
|
||||
get() = isNotInstalled || isNotAlive || isNotGranted
|
||||
}
|
||||
|
||||
data class AppDetailUiState(
|
||||
val products: List<Product> = emptyList(),
|
||||
val repos: List<Repository> = emptyList(),
|
||||
val installedItem: InstalledItem? = null,
|
||||
val isSelf: Boolean = false,
|
||||
val isFavourite: Boolean = false,
|
||||
val allowIncompatibleVersions: Boolean = false,
|
||||
val addressIfUnavailable: String? = null
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user