v0.6.4
This commit is contained in:
@@ -1,307 +1,29 @@
|
||||
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 com.looker.core.common.getInstallPackageName
|
||||
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() {
|
||||
class MainActivity : ScreenActivity() {
|
||||
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?) {
|
||||
override 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_UPDATES -> handleSpecialIntent(SpecialIntent.Updates)
|
||||
ACTION_INSTALL -> handleSpecialIntent(
|
||||
SpecialIntent.Install(
|
||||
intent.getInstallPackageName,
|
||||
intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
|
||||
)
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
else -> super.handleIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -6,45 +6,39 @@ 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 coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.looker.core.common.Constants
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.common.extension.getInstalledPackagesCompat
|
||||
import com.looker.core.common.extension.jobScheduler
|
||||
import com.looker.core.common.log
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.core.datastore.model.AutoSync
|
||||
import com.looker.core.datastore.model.InstallerType
|
||||
import com.looker.core.datastore.model.ProxyPreference
|
||||
import com.looker.core.datastore.model.ProxyType
|
||||
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 com.looker.installer.InstallManager
|
||||
import com.looker.installer.installers.root.RootPermissionHandler
|
||||
import com.looker.installer.installers.shizuku.ShizukuPermissionHandler
|
||||
import com.looker.network.Downloader
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -58,9 +52,10 @@ import java.net.Proxy
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.INFINITE
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import com.looker.core.common.R as CommonR
|
||||
|
||||
@HiltAndroidApp
|
||||
class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Provider {
|
||||
class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider {
|
||||
|
||||
private val parentJob = SupervisorJob()
|
||||
private val appScope = CoroutineScope(Dispatchers.Default + parentJob)
|
||||
@@ -74,21 +69,25 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
|
||||
@Inject
|
||||
lateinit var downloader: Downloader
|
||||
|
||||
@Inject
|
||||
lateinit var shizukuPermissionHandler: ShizukuPermissionHandler
|
||||
|
||||
@Inject
|
||||
lateinit var rootPermissionHandler: RootPermissionHandler
|
||||
|
||||
@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() }
|
||||
setupInstaller()
|
||||
|
||||
if (databaseUpdated) forceSyncAll()
|
||||
}
|
||||
@@ -99,24 +98,53 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
|
||||
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")
|
||||
private fun setupInstaller() {
|
||||
appScope.launch {
|
||||
launch {
|
||||
settingsRepository.get { installerType }.collect {
|
||||
if (it == InstallerType.SHIZUKU) handleShizukuInstaller()
|
||||
if (it == InstallerType.ROOT) {
|
||||
if (!rootPermissionHandler.isGranted) {
|
||||
settingsRepository.setInstallerType(InstallerType.Default)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
val installedItems =
|
||||
packageManager.getInstalledPackagesCompat()
|
||||
?.map { it.toInstalledItem() }
|
||||
?: return@launch
|
||||
Database.InstalledAdapter.putAll(installedItems)
|
||||
}
|
||||
installer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.handleShizukuInstaller() = launch {
|
||||
shizukuPermissionHandler.state.collect { (isGranted, isAlive, _) ->
|
||||
if (isAlive && isGranted) {
|
||||
settingsRepository.setInstallerType(InstallerType.SHIZUKU)
|
||||
return@collect
|
||||
}
|
||||
if (isAlive) {
|
||||
settingsRepository.setInstallerType(InstallerType.Default)
|
||||
shizukuPermissionHandler.requestPermission()
|
||||
return@collect
|
||||
}
|
||||
settingsRepository.setInstallerType(InstallerType.Default)
|
||||
}
|
||||
}
|
||||
|
||||
private fun listenApplications() {
|
||||
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
|
||||
Database.InstalledAdapter.putAll(installedItems)
|
||||
}
|
||||
|
||||
private fun checkLanguage() {
|
||||
appScope.launch {
|
||||
val lastSetLanguage = settingsRepository.getInitial().language
|
||||
@@ -223,14 +251,9 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
|
||||
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)
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
val memoryCache = MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
|
||||
val diskCache = DiskCache.Builder()
|
||||
@@ -241,27 +264,13 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
|
||||
return ImageLoader.Builder(this)
|
||||
.memoryCache(memoryCache)
|
||||
.diskCache(diskCache)
|
||||
.error(getDrawableCompat(R.drawable.ic_cannot_load).asImage())
|
||||
.error(CommonR.drawable.ic_cannot_load)
|
||||
.crossfade(350)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun strictThreadPolicy() {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectDiskReads()
|
||||
.detectDiskWrites()
|
||||
.detectNetwork()
|
||||
.detectUnbufferedIo()
|
||||
.penaltyLog()
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
308
app/src/main/kotlin/com/looker/droidify/ScreenActivity.kt
Normal file
308
app/src/main/kotlin/com/looker/droidify/ScreenActivity.kt
Normal file
@@ -0,0 +1,308 @@
|
||||
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.core.common.DeeplinkType
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.deeplinkType
|
||||
import com.looker.core.common.extension.homeAsUp
|
||||
import com.looker.core.common.extension.inputManager
|
||||
import com.looker.core.common.requestNotificationPermission
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.extension.getThemeRes
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.droidify.database.CursorOwner
|
||||
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 com.looker.installer.InstallManager
|
||||
import com.looker.installer.model.installFrom
|
||||
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
|
||||
abstract class ScreenActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private const val STATE_FRAGMENT_STACK = "fragmentStack"
|
||||
}
|
||||
|
||||
sealed interface SpecialIntent {
|
||||
data object Updates : SpecialIntent
|
||||
class Install(val packageName: String?, val cacheFileName: String?) : SpecialIntent
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
protected fun handleSpecialIntent(specialIntent: SpecialIntent) {
|
||||
when (specialIntent) {
|
||||
is SpecialIntent.Updates -> {
|
||||
if (currentFragment !is TabsFragment) {
|
||||
fragmentStack.clear()
|
||||
replaceFragment(TabsFragment(), true)
|
||||
}
|
||||
val tabsFragment = currentFragment as TabsFragment
|
||||
tabsFragment.selectUpdates()
|
||||
backHandler()
|
||||
}
|
||||
|
||||
is SpecialIntent.Install -> {
|
||||
val packageName = specialIntent.packageName
|
||||
if (!packageName.isNullOrEmpty()) {
|
||||
navigateProduct(packageName)
|
||||
specialIntent.cacheFileName?.also { cacheFile ->
|
||||
val installItem = packageName installFrom cacheFile
|
||||
lifecycleScope.launch { installer install installItem }
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}::class
|
||||
}
|
||||
|
||||
open fun handleIntent(intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun navigateFavourites() = pushFragment(FavouritesFragment())
|
||||
internal fun navigateProduct(packageName: String, repoAddress: String? = null) =
|
||||
pushFragment(AppDetailFragment(packageName, repoAddress))
|
||||
|
||||
internal fun navigateRepositories() = pushFragment(RepositoriesFragment())
|
||||
internal fun navigatePreferences() = pushFragment(SettingsFragment.newInstance())
|
||||
internal fun navigateAddRepository(repoAddress: String? = null) =
|
||||
pushFragment(EditRepositoryFragment(null, repoAddress))
|
||||
|
||||
internal fun navigateRepository(repositoryId: Long) =
|
||||
pushFragment(RepositoryFragment(repositoryId))
|
||||
|
||||
internal fun navigateEditRepository(repositoryId: Long) =
|
||||
pushFragment(EditRepositoryFragment(repositoryId, null))
|
||||
}
|
||||
@@ -2,9 +2,9 @@ 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.core.common.extension.Json
|
||||
import com.looker.core.common.extension.parseDictionary
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.droidify.model.ProductPreference
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.utility.serialization.productPreference
|
||||
|
||||
@@ -5,36 +5,35 @@ 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.core.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(
|
||||
data class ProductsAvailable(
|
||||
val searchQuery: String,
|
||||
val section: ProductItem.Section,
|
||||
val order: SortOrder,
|
||||
val order: SortOrder
|
||||
) : Request() {
|
||||
override val id: Int
|
||||
get() = 1
|
||||
}
|
||||
|
||||
class Installed(
|
||||
data class ProductsInstalled(
|
||||
val searchQuery: String,
|
||||
val section: ProductItem.Section,
|
||||
val order: SortOrder,
|
||||
val order: SortOrder
|
||||
) : Request() {
|
||||
override val id: Int
|
||||
get() = 2
|
||||
}
|
||||
|
||||
class Updates(
|
||||
data class ProductsUpdates(
|
||||
val searchQuery: String,
|
||||
val section: ProductItem.Section,
|
||||
val order: SortOrder,
|
||||
val skipSignatureCheck: Boolean,
|
||||
val order: SortOrder
|
||||
) : Request() {
|
||||
override val id: Int
|
||||
get() = 3
|
||||
@@ -53,7 +52,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
private data class ActiveRequest(
|
||||
val request: Request,
|
||||
val callback: Callback?,
|
||||
val cursor: Cursor?,
|
||||
val cursor: Cursor?
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -94,7 +93,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
val request = activeRequests[id]!!.request
|
||||
return QueryLoader(requireContext()) {
|
||||
when (request) {
|
||||
is Request.Available ->
|
||||
is Request.ProductsAvailable ->
|
||||
Database.ProductAdapter
|
||||
.query(
|
||||
installed = false,
|
||||
@@ -102,10 +101,10 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
searchQuery = request.searchQuery,
|
||||
section = request.section,
|
||||
order = request.order,
|
||||
signal = it,
|
||||
signal = it
|
||||
)
|
||||
|
||||
is Request.Installed ->
|
||||
is Request.ProductsInstalled ->
|
||||
Database.ProductAdapter
|
||||
.query(
|
||||
installed = true,
|
||||
@@ -113,10 +112,10 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
searchQuery = request.searchQuery,
|
||||
section = request.section,
|
||||
order = request.order,
|
||||
signal = it,
|
||||
signal = it
|
||||
)
|
||||
|
||||
is Request.Updates ->
|
||||
is Request.ProductsUpdates ->
|
||||
Database.ProductAdapter
|
||||
.query(
|
||||
installed = true,
|
||||
@@ -124,8 +123,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
searchQuery = request.searchQuery,
|
||||
section = request.section,
|
||||
order = request.order,
|
||||
signal = it,
|
||||
skipSignatureCheck = request.skipSignatureCheck,
|
||||
signal = it
|
||||
)
|
||||
|
||||
is Request.Repositories -> Database.RepositoryAdapter.query(it)
|
||||
|
||||
@@ -9,18 +9,18 @@ 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.core.common.extension.Json
|
||||
import com.looker.core.common.extension.asSequence
|
||||
import com.looker.core.common.extension.firstOrNull
|
||||
import com.looker.core.common.extension.parseDictionary
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.core.common.log
|
||||
import com.looker.core.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.BuildConfig
|
||||
import com.looker.droidify.utility.serialization.product
|
||||
import com.looker.droidify.utility.serialization.productItem
|
||||
import com.looker.droidify.utility.serialization.repository
|
||||
@@ -71,20 +71,14 @@ object Database {
|
||||
get() = "$databasePrefix$innerName"
|
||||
|
||||
fun formatCreateTable(name: String): String {
|
||||
return buildString(128) {
|
||||
append("CREATE TABLE ")
|
||||
append(name)
|
||||
append(" (")
|
||||
trimAndJoin(createTable)
|
||||
append(")")
|
||||
}
|
||||
return "CREATE TABLE $name (${QueryBuilder.trimQuery(createTable)})"
|
||||
}
|
||||
|
||||
val createIndexPairFormatted: Pair<String, String>?
|
||||
get() = createIndex?.let {
|
||||
Pair(
|
||||
"CREATE INDEX ${innerName}_index ON $innerName ($it)",
|
||||
"CREATE INDEX ${name}_index ON $innerName ($it)",
|
||||
"CREATE INDEX ${name}_index ON $innerName ($it)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -190,7 +184,7 @@ object Database {
|
||||
}
|
||||
}
|
||||
|
||||
private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 5) {
|
||||
private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 4) {
|
||||
var created = false
|
||||
private set
|
||||
var updated = false
|
||||
@@ -220,7 +214,7 @@ object Database {
|
||||
Schema.Product,
|
||||
Schema.Category,
|
||||
Schema.Installed,
|
||||
Schema.Lock,
|
||||
Schema.Lock
|
||||
)
|
||||
dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category)
|
||||
this.created = this.created || create
|
||||
@@ -233,7 +227,7 @@ object Database {
|
||||
val sql = db.query(
|
||||
"${table.databasePrefix}sqlite_master",
|
||||
columns = arrayOf("sql"),
|
||||
selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName)),
|
||||
selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName))
|
||||
).use { it.firstOrNull()?.getString(0) }.orEmpty()
|
||||
table.formatCreateTable(table.innerName) != sql
|
||||
}
|
||||
@@ -267,7 +261,7 @@ object Database {
|
||||
val sqls = db.query(
|
||||
"${table.databasePrefix}sqlite_master",
|
||||
columns = arrayOf("name", "sql"),
|
||||
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName)),
|
||||
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName))
|
||||
)
|
||||
.use { cursor ->
|
||||
cursor.asSequence()
|
||||
@@ -295,7 +289,7 @@ object Database {
|
||||
val tables = db.query(
|
||||
"sqlite_master",
|
||||
columns = arrayOf("name"),
|
||||
selection = Pair("type = ?", arrayOf("table")),
|
||||
selection = Pair("type = ?", arrayOf("table"))
|
||||
)
|
||||
.use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() }
|
||||
.filter { !it.startsWith("sqlite_") && !it.startsWith("android_") }
|
||||
@@ -351,7 +345,7 @@ object Database {
|
||||
private fun SQLiteDatabase.insertOrReplace(
|
||||
replace: Boolean,
|
||||
table: String,
|
||||
contentValues: ContentValues,
|
||||
contentValues: ContentValues
|
||||
): Long {
|
||||
return if (replace) {
|
||||
replace(table, null, contentValues)
|
||||
@@ -359,7 +353,7 @@ object Database {
|
||||
insert(
|
||||
table,
|
||||
null,
|
||||
contentValues,
|
||||
contentValues
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -369,7 +363,7 @@ object Database {
|
||||
columns: Array<String>? = null,
|
||||
selection: Pair<String, Array<String>>? = null,
|
||||
orderBy: String? = null,
|
||||
signal: CancellationSignal? = null,
|
||||
signal: CancellationSignal? = null
|
||||
): Cursor {
|
||||
return query(
|
||||
false,
|
||||
@@ -381,7 +375,7 @@ object Database {
|
||||
null,
|
||||
orderBy,
|
||||
null,
|
||||
signal,
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
@@ -403,7 +397,7 @@ object Database {
|
||||
internal fun putWithoutNotification(
|
||||
repository: Repository,
|
||||
shouldReplace: Boolean,
|
||||
database: SQLiteDatabase,
|
||||
database: SQLiteDatabase
|
||||
): Long {
|
||||
return database.insertOrReplace(
|
||||
shouldReplace,
|
||||
@@ -415,7 +409,7 @@ object Database {
|
||||
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))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -448,8 +442,8 @@ object Database {
|
||||
Schema.Repository.name,
|
||||
selection = Pair(
|
||||
"${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0",
|
||||
arrayOf(id.toString()),
|
||||
),
|
||||
arrayOf(id.toString())
|
||||
)
|
||||
).use { it.firstOrNull()?.let(::transform) }
|
||||
}
|
||||
|
||||
@@ -469,9 +463,9 @@ object Database {
|
||||
selection = Pair(
|
||||
"${Schema.Repository.ROW_ENABLED} != 0 AND " +
|
||||
"${Schema.Repository.ROW_DELETED} == 0",
|
||||
emptyArray(),
|
||||
emptyArray()
|
||||
),
|
||||
signal = null,
|
||||
signal = null
|
||||
).use { it.asSequence().map(::transform).toList() }
|
||||
}
|
||||
|
||||
@@ -479,7 +473,7 @@ object Database {
|
||||
return db.query(
|
||||
Schema.Repository.name,
|
||||
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
|
||||
signal = null,
|
||||
signal = null
|
||||
).use { it.asSequence().map(::transform).toList() }
|
||||
}
|
||||
|
||||
@@ -495,9 +489,9 @@ object Database {
|
||||
selection = Pair(
|
||||
"${Schema.Repository.ROW_ENABLED} == 0 OR " +
|
||||
"${Schema.Repository.ROW_DELETED} != 0",
|
||||
emptyArray(),
|
||||
emptyArray()
|
||||
),
|
||||
signal = null,
|
||||
signal = null
|
||||
).use { parentCursor ->
|
||||
parentCursor.asSequence().associate {
|
||||
val idIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_ID)
|
||||
@@ -514,7 +508,7 @@ object Database {
|
||||
put(Schema.Repository.ROW_DELETED, 1)
|
||||
},
|
||||
"${Schema.Repository.ROW_ID} = ?",
|
||||
arrayOf(id.toString()),
|
||||
arrayOf(id.toString())
|
||||
)
|
||||
notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products)
|
||||
}
|
||||
@@ -525,18 +519,18 @@ object Database {
|
||||
val productsCount = db.delete(
|
||||
Schema.Product.name,
|
||||
"${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)",
|
||||
null,
|
||||
null
|
||||
)
|
||||
val categoriesCount = db.delete(
|
||||
Schema.Category.name,
|
||||
"${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)",
|
||||
null,
|
||||
null
|
||||
)
|
||||
if (isDeleted) {
|
||||
db.delete(
|
||||
Schema.Repository.name,
|
||||
"${Schema.Repository.ROW_ID} IN ($id)",
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
productsCount != 0 || categoriesCount != 0
|
||||
@@ -561,7 +555,7 @@ object Database {
|
||||
Schema.Repository.name,
|
||||
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
|
||||
orderBy = "${Schema.Repository.ROW_ENABLED} DESC",
|
||||
signal = signal,
|
||||
signal = signal
|
||||
).observable(Subject.Repositories)
|
||||
}
|
||||
|
||||
@@ -583,28 +577,26 @@ object Database {
|
||||
.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()
|
||||
}
|
||||
suspend fun getUpdates(): List<ProductItem> = withContext(Dispatchers.IO) {
|
||||
query(
|
||||
installed = true,
|
||||
updates = true,
|
||||
searchQuery = "",
|
||||
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)
|
||||
fun getUpdatesStream(): 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) }
|
||||
.map { getUpdates() }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun get(packageName: String, signal: CancellationSignal?): List<Product> {
|
||||
@@ -613,10 +605,10 @@ object Database {
|
||||
columns = arrayOf(
|
||||
Schema.Product.ROW_REPOSITORY_ID,
|
||||
Schema.Product.ROW_DESCRIPTION,
|
||||
Schema.Product.ROW_DATA,
|
||||
Schema.Product.ROW_DATA
|
||||
),
|
||||
selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
|
||||
signal = signal,
|
||||
signal = signal
|
||||
).use { it.asSequence().map(::transform).toList() }
|
||||
}
|
||||
|
||||
@@ -631,26 +623,24 @@ object Database {
|
||||
columns = arrayOf("COUNT (*)"),
|
||||
selection = Pair(
|
||||
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
|
||||
arrayOf(repositoryId.toString()),
|
||||
),
|
||||
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?,
|
||||
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} != ''"""
|
||||
val signatureMatches = """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},
|
||||
@@ -738,10 +728,6 @@ object Database {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -807,10 +793,10 @@ object Database {
|
||||
Schema.Installed.ROW_PACKAGE_NAME,
|
||||
Schema.Installed.ROW_VERSION,
|
||||
Schema.Installed.ROW_VERSION_CODE,
|
||||
Schema.Installed.ROW_SIGNATURE,
|
||||
Schema.Installed.ROW_SIGNATURE
|
||||
),
|
||||
selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
|
||||
signal = signal,
|
||||
signal = signal
|
||||
).use { it.firstOrNull()?.let(::transform) }
|
||||
}
|
||||
|
||||
@@ -823,7 +809,7 @@ object Database {
|
||||
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)
|
||||
@@ -843,7 +829,7 @@ object Database {
|
||||
val count = db.delete(
|
||||
Schema.Installed.name,
|
||||
"${Schema.Installed.ROW_PACKAGE_NAME} = ?",
|
||||
arrayOf(packageName),
|
||||
arrayOf(packageName)
|
||||
)
|
||||
if (count > 0) {
|
||||
notifyChanged(Subject.Products)
|
||||
@@ -855,7 +841,7 @@ object Database {
|
||||
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)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_SIGNATURE))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -868,7 +854,7 @@ object Database {
|
||||
ContentValues().apply {
|
||||
put(Schema.Lock.ROW_PACKAGE_NAME, lock.first)
|
||||
put(Schema.Lock.ROW_VERSION_CODE, lock.second)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (notify) {
|
||||
notifyChanged(Subject.Products)
|
||||
@@ -924,9 +910,9 @@ object Database {
|
||||
put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize))
|
||||
put(
|
||||
Schema.Product.ROW_DATA_ITEM,
|
||||
jsonGenerate(product.item()::serialize),
|
||||
jsonGenerate(product.item()::serialize)
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
for (category in product.categories) {
|
||||
db.insertOrReplace(
|
||||
@@ -936,7 +922,7 @@ object Database {
|
||||
put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId)
|
||||
put(Schema.Category.ROW_PACKAGE_NAME, product.packageName)
|
||||
put(Schema.Category.ROW_NAME, category)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -949,20 +935,20 @@ object Database {
|
||||
db.delete(
|
||||
Schema.Product.name,
|
||||
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
|
||||
arrayOf(repository.id.toString()),
|
||||
arrayOf(repository.id.toString())
|
||||
)
|
||||
db.delete(
|
||||
Schema.Category.name,
|
||||
"${Schema.Category.ROW_REPOSITORY_ID} = ?",
|
||||
arrayOf(repository.id.toString()),
|
||||
arrayOf(repository.id.toString())
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO ${Schema.Product.name} SELECT * " +
|
||||
"FROM ${Schema.Product.temporaryName}",
|
||||
"FROM ${Schema.Product.temporaryName}"
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO ${Schema.Category.name} SELECT * " +
|
||||
"FROM ${Schema.Category.temporaryName}",
|
||||
"FROM ${Schema.Category.temporaryName}"
|
||||
)
|
||||
RepositoryAdapter.putWithoutNotification(repository, true, db)
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
||||
@@ -971,7 +957,7 @@ object Database {
|
||||
notifyChanged(
|
||||
Subject.Repositories,
|
||||
Subject.Repository(repository.id),
|
||||
Subject.Products,
|
||||
Subject.Products
|
||||
)
|
||||
} else {
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
||||
|
||||
@@ -3,20 +3,26 @@ package com.looker.droidify.database
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.CancellationSignal
|
||||
import com.looker.core.common.extension.asSequence
|
||||
import com.looker.core.common.log
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.utility.common.extension.asSequence
|
||||
import com.looker.droidify.utility.common.log
|
||||
|
||||
class QueryBuilder {
|
||||
companion object {
|
||||
fun trimQuery(query: String): String {
|
||||
return query.lines().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
.joinToString(separator = " ")
|
||||
}
|
||||
}
|
||||
|
||||
private val builder = StringBuilder(256)
|
||||
private val builder = StringBuilder()
|
||||
private val arguments = mutableListOf<String>()
|
||||
|
||||
operator fun plusAssign(query: String) {
|
||||
if (builder.isNotEmpty()) {
|
||||
builder.append(" ")
|
||||
}
|
||||
builder.trimAndJoin(query)
|
||||
builder.append(trimQuery(query))
|
||||
}
|
||||
|
||||
operator fun remAssign(argument: String) {
|
||||
@@ -42,53 +48,3 @@ class QueryBuilder {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@ 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.core.common.Exporter
|
||||
import com.looker.core.common.extension.Json
|
||||
import com.looker.core.common.extension.forEach
|
||||
import com.looker.core.common.extension.forEachKey
|
||||
import com.looker.core.common.extension.parseDictionary
|
||||
import com.looker.core.common.extension.writeArray
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.core.di.ApplicationScope
|
||||
import com.looker.core.di.IoDispatcher
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.utility.serialization.repository
|
||||
import com.looker.droidify.utility.serialization.serialize
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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)
|
||||
}
|
||||
} ?: ""
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
enum class AutoSync {
|
||||
ALWAYS,
|
||||
WIFI_ONLY,
|
||||
WIFI_PLUGGED_IN,
|
||||
NEVER
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
enum class ProxyType {
|
||||
DIRECT,
|
||||
HTTP,
|
||||
SOCKS
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
// todo: Add Support for sorting by size
|
||||
enum class SortOrder {
|
||||
UPDATED,
|
||||
ADDED,
|
||||
NAME
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
enum class Theme {
|
||||
SYSTEM,
|
||||
SYSTEM_BLACK,
|
||||
LIGHT,
|
||||
DARK,
|
||||
AMOLED
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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,
|
||||
)
|
||||
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
interface DataFile {
|
||||
val name: String
|
||||
val hash: String
|
||||
val size: Long
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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("")
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
@JvmInline
|
||||
value class PackageName(val name: String)
|
||||
|
||||
fun String.toPackageName() = PackageName(this)
|
||||
@@ -1,49 +0,0 @@
|
||||
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?
|
||||
)
|
||||
@@ -51,6 +51,7 @@ open class DrawableWrapper(val drawable: Drawable) : Drawable() {
|
||||
drawable.colorFilter = colorFilter
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
@Suppress("DEPRECATION")
|
||||
override fun getOpacity(): Int = drawable.opacity
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ 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.core.common.extension.Json
|
||||
import com.looker.core.common.extension.asSequence
|
||||
import com.looker.core.common.extension.collectNotNull
|
||||
import com.looker.core.common.extension.execWithResult
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.utility.serialization.product
|
||||
@@ -82,9 +83,9 @@ class IndexMerger(file: File) : Closeable {
|
||||
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""",
|
||||
LEFT JOIN releases ON product.package_name = releases.package_name""",
|
||||
null
|
||||
).use { cursor ->
|
||||
)?.use { cursor ->
|
||||
cursor.asSequence().map { currentCursor ->
|
||||
val description = currentCursor.getString(0)
|
||||
val product = Json.factory.createParser(currentCursor.getBlob(1)).use {
|
||||
@@ -111,8 +112,4 @@ class IndexMerger(file: File) : Closeable {
|
||||
override fun close() {
|
||||
db.use { closeTransaction() }
|
||||
}
|
||||
|
||||
private inline fun SQLiteDatabase.execWithResult(sql: String) {
|
||||
rawQuery(sql, null).use { it.count }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,27 +5,16 @@ 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.core.common.SdkCheck
|
||||
import com.looker.core.common.extension.Json
|
||||
import com.looker.core.common.extension.collectDistinctNotEmptyStrings
|
||||
import com.looker.core.common.extension.collectNotNull
|
||||
import com.looker.core.common.extension.forEach
|
||||
import com.looker.core.common.extension.forEachKey
|
||||
import com.looker.core.common.extension.illegal
|
||||
import com.looker.core.common.nullIfEmpty
|
||||
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 {
|
||||
@@ -43,12 +32,9 @@ object IndexV1Parser {
|
||||
}
|
||||
|
||||
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>,
|
||||
val largeTablet: List<String>
|
||||
)
|
||||
|
||||
private class Localized(
|
||||
@@ -104,9 +90,10 @@ object IndexV1Parser {
|
||||
}
|
||||
|
||||
private fun <T> Map<String, Localized>.find(callback: (String, Localized) -> T?): T? {
|
||||
return getAndCall("en-US", callback)
|
||||
?: getAndCall("en_US", callback)
|
||||
?: getAndCall("en", callback)
|
||||
return getAndCall("en-US", callback) ?: getAndCall("en_US", callback) ?: getAndCall(
|
||||
"en",
|
||||
callback
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> Map<String, Localized>.findLocalized(callback: (Localized) -> T?): T? {
|
||||
@@ -135,11 +122,12 @@ object IndexV1Parser {
|
||||
|
||||
internal object DonateComparator : Comparator<Product.Donate> {
|
||||
private val classes = listOf(
|
||||
Regular::class,
|
||||
Bitcoin::class,
|
||||
Litecoin::class,
|
||||
Liberapay::class,
|
||||
OpenCollective::class
|
||||
Product.Donate.Regular::class,
|
||||
Product.Donate.Bitcoin::class,
|
||||
Product.Donate.Litecoin::class,
|
||||
Product.Donate.Flattr::class,
|
||||
Product.Donate.Liberapay::class,
|
||||
Product.Donate.OpenCollective::class
|
||||
)
|
||||
|
||||
override fun compare(donate1: Product.Donate, donate2: Product.Donate): Int {
|
||||
@@ -153,25 +141,14 @@ object IndexV1Parser {
|
||||
}
|
||||
}
|
||||
|
||||
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 ->
|
||||
jsonParser.forEachKey { it ->
|
||||
when {
|
||||
key.dictionary(DICT_REPO) -> {
|
||||
it.dictionary("repo") -> {
|
||||
var address = ""
|
||||
var mirrors = emptyList<String>()
|
||||
var name = ""
|
||||
@@ -180,14 +157,12 @@ object IndexV1Parser {
|
||||
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
|
||||
it.string("address") -> address = valueAsString
|
||||
it.array("mirrors") -> mirrors = collectDistinctNotEmptyStrings()
|
||||
it.string("name") -> name = valueAsString
|
||||
it.string("description") -> description = valueAsString
|
||||
it.number("version") -> version = valueAsInt
|
||||
it.number("timestamp") -> timestamp = valueAsLong
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
@@ -207,12 +182,12 @@ object IndexV1Parser {
|
||||
)
|
||||
}
|
||||
|
||||
key.array(DICT_PRODUCT) -> forEach(JsonToken.START_OBJECT) {
|
||||
it.array("apps") -> forEach(JsonToken.START_OBJECT) {
|
||||
val product = parseProduct(repositoryId)
|
||||
callback.onProduct(product)
|
||||
}
|
||||
|
||||
key.dictionary(DICT_RELEASE) -> forEachKey {
|
||||
it.dictionary("packages") -> forEachKey {
|
||||
if (it.token == JsonToken.START_ARRAY) {
|
||||
val packageName = it.key
|
||||
val releases = collectNotNull(JsonToken.START_OBJECT) { parseRelease() }
|
||||
@@ -228,38 +203,6 @@ object IndexV1Parser {
|
||||
}
|
||||
}
|
||||
|
||||
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 = ""
|
||||
@@ -281,42 +224,42 @@ object IndexV1Parser {
|
||||
val licenses = mutableListOf<String>()
|
||||
val donates = mutableListOf<Product.Donate>()
|
||||
val localizedMap = mutableMapOf<String, Localized>()
|
||||
forEachKey { key ->
|
||||
forEachKey { it ->
|
||||
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) ->
|
||||
it.string("packageName") -> packageName = valueAsString
|
||||
it.string("name") -> nameFallback = valueAsString
|
||||
it.string("summary") -> summaryFallback = valueAsString
|
||||
it.string("description") -> descriptionFallback = valueAsString
|
||||
it.string("icon") -> icon = validateIcon(valueAsString)
|
||||
it.string("authorName") -> authorName = valueAsString
|
||||
it.string("authorEmail") -> authorEmail = valueAsString
|
||||
it.string("authorWebSite") -> authorWeb = valueAsString
|
||||
it.string("sourceCode") -> source = valueAsString
|
||||
it.string("changelog") -> changelog = valueAsString
|
||||
it.string("webSite") -> web = valueAsString
|
||||
it.string("issueTracker") -> tracker = valueAsString
|
||||
it.number("added") -> added = valueAsLong
|
||||
it.number("lastUpdated") -> updated = valueAsLong
|
||||
it.string("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(',')
|
||||
it.array("categories") -> categories = collectDistinctNotEmptyStrings()
|
||||
it.array("antiFeatures") -> antiFeatures = collectDistinctNotEmptyStrings()
|
||||
it.string("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)
|
||||
it.string("donate") -> donates += Product.Donate.Regular(valueAsString)
|
||||
it.string("bitcoin") -> donates += Product.Donate.Bitcoin(valueAsString)
|
||||
it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString)
|
||||
it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString)
|
||||
it.string("openCollective") -> donates += Product.Donate.OpenCollective(
|
||||
valueAsString
|
||||
)
|
||||
|
||||
key.dictionary(KEY_PRODUCT_LOCALIZED) -> forEachKey { localizedKey ->
|
||||
if (localizedKey.token == JsonToken.START_OBJECT) {
|
||||
val locale = localizedKey.key
|
||||
it.dictionary("localized") -> forEachKey { it ->
|
||||
if (it.token == JsonToken.START_OBJECT) {
|
||||
val locale = it.key
|
||||
var name = ""
|
||||
var summary = ""
|
||||
var description = ""
|
||||
@@ -325,52 +268,46 @@ object IndexV1Parser {
|
||||
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.string("name") -> name = valueAsString
|
||||
it.string("summary") -> summary = valueAsString
|
||||
it.string("description") -> description = valueAsString
|
||||
it.string("whatsNew") -> whatsNew = valueAsString
|
||||
it.string("icon") -> metadataIcon = valueAsString
|
||||
it.array("phoneScreenshots") ->
|
||||
phone =
|
||||
collectDistinctNotEmptyStrings()
|
||||
|
||||
it.array(KEY_PRODUCT_SEVEN_INCH_SCREENSHOTS) ->
|
||||
smallTablet = collectDistinctNotEmptyStrings()
|
||||
it.array("sevenInchScreenshots") ->
|
||||
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()
|
||||
it.array("tenInchScreenshots") ->
|
||||
largeTablet =
|
||||
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)
|
||||
if (sequenceOf(
|
||||
phone,
|
||||
smallTablet,
|
||||
largeTablet
|
||||
).any { it.isNotEmpty() }
|
||||
) {
|
||||
Screenshots(phone, smallTablet, largeTablet)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
localizedMap[locale] = Localized(
|
||||
name = name,
|
||||
summary = summary,
|
||||
description = description,
|
||||
whatsNew = whatsNew,
|
||||
metadataIcon = metadataIcon.nullIfEmpty()?.let { "$locale/$it" }
|
||||
.orEmpty(),
|
||||
screenshots = screenshots,
|
||||
name,
|
||||
summary,
|
||||
description,
|
||||
whatsNew,
|
||||
metadataIcon.nullIfEmpty()?.let { "$locale/$it" }.orEmpty(),
|
||||
screenshots
|
||||
)
|
||||
} else {
|
||||
skipChildren()
|
||||
@@ -393,61 +330,54 @@ object IndexV1Parser {
|
||||
}
|
||||
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()
|
||||
val screenshots = screenshotPairs
|
||||
?.let { (key, screenshots) ->
|
||||
screenshots.phone.asSequence()
|
||||
.map { Product.Screenshot(key, Product.Screenshot.Type.PHONE, it) } +
|
||||
screenshots.smallTablet.asSequence()
|
||||
.map {
|
||||
Product.Screenshot(
|
||||
key,
|
||||
Product.Screenshot.Type.SMALL_TABLET,
|
||||
it
|
||||
)
|
||||
} +
|
||||
screenshots.largeTablet.asSequence()
|
||||
.map {
|
||||
Product.Screenshot(
|
||||
key,
|
||||
Product.Screenshot.Type.LARGE_TABLET,
|
||||
it
|
||||
)
|
||||
}
|
||||
}
|
||||
.orEmpty().toList()
|
||||
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()
|
||||
repositoryId,
|
||||
packageName,
|
||||
name,
|
||||
summary,
|
||||
description,
|
||||
whatsNew,
|
||||
icon,
|
||||
metadataIcon,
|
||||
Product.Author(authorName, authorEmail, authorWeb),
|
||||
source,
|
||||
changelog,
|
||||
web,
|
||||
tracker,
|
||||
added,
|
||||
updated,
|
||||
suggestedVersionCode,
|
||||
categories,
|
||||
antiFeatures,
|
||||
licenses,
|
||||
donates.sortedWith(DonateComparator),
|
||||
screenshots,
|
||||
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
|
||||
@@ -468,28 +398,28 @@ object IndexV1Parser {
|
||||
val permissions = linkedSetOf<String>()
|
||||
var features = emptyList<String>()
|
||||
var platforms = emptyList<String>()
|
||||
forEachKey { key ->
|
||||
forEachKey {
|
||||
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()
|
||||
it.string("versionName") -> version = valueAsString
|
||||
it.number("versionCode") -> versionCode = valueAsLong
|
||||
it.number("added") -> added = valueAsLong
|
||||
it.number("size") -> size = valueAsLong
|
||||
it.number("minSdkVersion") -> minSdkVersion = valueAsInt
|
||||
it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt
|
||||
it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt
|
||||
it.string("srcname") -> source = valueAsString
|
||||
it.string("apkName") -> release = valueAsString
|
||||
it.string("hash") -> hash = valueAsString
|
||||
it.string("hashType") -> hashTypeCandidate = valueAsString
|
||||
it.string("sig") -> signature = valueAsString
|
||||
it.string("obbMainFile") -> obbMain = valueAsString
|
||||
it.string("obbMainFileSha256") -> obbMainHash = valueAsString
|
||||
it.string("obbPatchFile") -> obbPatch = valueAsString
|
||||
it.string("obbPatchFileSha256") -> obbPatchHash = valueAsString
|
||||
it.array("uses-permission") -> collectPermissions(permissions, 0)
|
||||
it.array("uses-permission-sdk-23") -> collectPermissions(permissions, 23)
|
||||
it.array("features") -> features = collectDistinctNotEmptyStrings()
|
||||
it.array("nativecode") -> platforms = collectDistinctNotEmptyStrings()
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
@@ -498,29 +428,29 @@ object IndexV1Parser {
|
||||
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()
|
||||
false,
|
||||
version,
|
||||
versionCode,
|
||||
added,
|
||||
size,
|
||||
minSdkVersion,
|
||||
targetSdkVersion,
|
||||
maxSdkVersion,
|
||||
source,
|
||||
release,
|
||||
hash,
|
||||
hashType,
|
||||
signature,
|
||||
obbMain,
|
||||
obbMainHash,
|
||||
obbMainHashType,
|
||||
obbPatch,
|
||||
obbPatchHash,
|
||||
obbPatchHashType,
|
||||
permissions.toList(),
|
||||
features,
|
||||
platforms,
|
||||
emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,28 +2,28 @@ 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.core.common.SdkCheck
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.common.extension.fingerprint
|
||||
import com.looker.core.common.extension.toFormattedString
|
||||
import com.looker.core.common.result.Result
|
||||
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.database.Database
|
||||
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 com.looker.network.Downloader
|
||||
import com.looker.network.NetworkResponse
|
||||
import java.io.File
|
||||
import java.security.CodeSigner
|
||||
import java.security.cert.Certificate
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarFile
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
object RepositoryUpdater {
|
||||
enum class Stage {
|
||||
@@ -31,7 +31,7 @@ object RepositoryUpdater {
|
||||
}
|
||||
|
||||
// TODO Add support for Index-V2 and also cleanup everything here
|
||||
enum class IndexType(
|
||||
private enum class IndexType(
|
||||
val jarName: String,
|
||||
val contentName: String
|
||||
) {
|
||||
@@ -219,13 +219,12 @@ object RepositoryUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
fun processFile(
|
||||
private 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
|
||||
@@ -242,6 +241,7 @@ object RepositoryUpdater {
|
||||
|
||||
var changedRepository: Repository? = null
|
||||
|
||||
val mergerFile = Cache.getTemporaryFile(context)
|
||||
try {
|
||||
val unmergedProducts = mutableListOf<Product>()
|
||||
val unmergedReleases = mutableListOf<Pair<String, List<Release>>>()
|
||||
@@ -344,7 +344,6 @@ object RepositoryUpdater {
|
||||
.codeSigner
|
||||
.certificate
|
||||
.fingerprint()
|
||||
.toString()
|
||||
.uppercase()
|
||||
|
||||
val commitRepository = if (!workRepository.fingerprint.equals(
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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)
|
||||
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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("]")
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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)
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.looker.droidify.installer.model
|
||||
|
||||
enum class InstallState { Failed, Pending, Installing, Installed }
|
||||
|
||||
inline val InstallState.isCancellable: Boolean
|
||||
get() = this == InstallState.Pending
|
||||
@@ -1,87 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
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,
|
||||
@@ -35,41 +30,20 @@ data class Product(
|
||||
data class Regular(val url: String) : Donate()
|
||||
data class Bitcoin(val address: String) : Donate()
|
||||
data class Litecoin(val address: String) : Donate()
|
||||
data class Flattr(val id: 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")
|
||||
LARGE_TABLET("largeTablet")
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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(
|
||||
@@ -18,39 +16,15 @@ data class ProductItem(
|
||||
var canUpdate: Boolean,
|
||||
var matchRank: Int
|
||||
) {
|
||||
sealed interface Section : Parcelable {
|
||||
sealed class Section : Parcelable {
|
||||
|
||||
@Parcelize
|
||||
object All : Section
|
||||
data object All : Section()
|
||||
|
||||
@Parcelize
|
||||
class Category(val name: String) : Section
|
||||
data 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
|
||||
data class Repository(val id: Long, val name: String) : Section()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ data class Release(
|
||||
object MinSdk : Incompatibility()
|
||||
object MaxSdk : Incompatibility()
|
||||
object Platform : Incompatibility()
|
||||
class Feature(val feature: String) : Incompatibility()
|
||||
data class Feature(val feature: String) : Incompatibility()
|
||||
}
|
||||
|
||||
val identifier: String
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
import com.looker.core.common.extension.isOnion
|
||||
import java.net.URL
|
||||
|
||||
data class Repository(
|
||||
@@ -15,9 +16,19 @@ data class Repository(
|
||||
val entityTag: String,
|
||||
val updated: Long,
|
||||
val timestamp: Long,
|
||||
val authentication: String,
|
||||
val authentication: String
|
||||
) {
|
||||
|
||||
/**
|
||||
* Remove all onion addresses and supply it as random address
|
||||
*
|
||||
* If the list only contains onion urls we will provide the default address
|
||||
*/
|
||||
val randomAddress: String
|
||||
get() = (mirrors + address)
|
||||
.filter { !it.isOnion }
|
||||
.randomOrNull() ?: address
|
||||
|
||||
fun edit(address: String, fingerprint: String, authentication: String): Repository {
|
||||
val isAddressChanged = this.address != address
|
||||
val isFingerprintChanged = this.fingerprint != fingerprint
|
||||
@@ -38,7 +49,7 @@ data class Repository(
|
||||
version: Int,
|
||||
lastModified: String,
|
||||
entityTag: String,
|
||||
timestamp: Long,
|
||||
timestamp: Long
|
||||
): Repository {
|
||||
return copy(
|
||||
mirrors = mirrors,
|
||||
@@ -62,7 +73,7 @@ data class Repository(
|
||||
fun newRepository(
|
||||
address: String,
|
||||
fingerprint: String,
|
||||
authentication: String,
|
||||
authentication: String
|
||||
): Repository {
|
||||
val name = try {
|
||||
URL(address).let { "${it.host}${it.path}" }
|
||||
@@ -79,7 +90,7 @@ data class Repository(
|
||||
version: Int = 21,
|
||||
enabled: Boolean = false,
|
||||
fingerprint: String,
|
||||
authentication: String = "",
|
||||
authentication: String = ""
|
||||
): Repository {
|
||||
return Repository(
|
||||
-1, address, emptyList(), name, description, version, enabled,
|
||||
@@ -150,6 +161,14 @@ data class Repository(
|
||||
" by Netsyms Technologies.",
|
||||
fingerprint = "2581BA7B32D3AB443180C4087CAB6A7E8FB258D3A6E98870ECB3C675E4D64489"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.bromite.org/fdroid/repo",
|
||||
name = "Bromite",
|
||||
description = "The official repository for Bromite. " +
|
||||
"Bromite is a Chromium with ad blocking and enhanced p" +
|
||||
"rivacy.",
|
||||
fingerprint = "E1EE5CD076D7B0DC84CB2B45FB78B86DF2EB39A3B6C56BA3DC292A5E0C3B9504"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://molly.im/fdroid/foss/fdroid/repo",
|
||||
name = "Molly",
|
||||
@@ -339,7 +358,10 @@ data class Repository(
|
||||
name = "SimpleX Chat F-Droid",
|
||||
description = "SimpleX Chat official F-Droid repository.",
|
||||
fingerprint = "9F358FF284D1F71656A2BFAF0E005DEAE6AA14143720E089F11FF2DDCFEB01BA"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val newlyAdded = listOf<Repository>(
|
||||
defaultRepository(
|
||||
address = "https://f-droid.monerujo.io/fdroid/repo",
|
||||
name = "Monerujo Wallet",
|
||||
@@ -389,29 +411,5 @@ data class Repository(
|
||||
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"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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
|
||||
@@ -1,153 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.looker.droidify.network.validation
|
||||
|
||||
import java.io.File
|
||||
|
||||
interface FileValidator {
|
||||
|
||||
@Throws(ValidationException::class)
|
||||
suspend fun validate(file: File)
|
||||
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
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)
|
||||
@@ -4,7 +4,7 @@ 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.core.common.extension.getPackageInfoCompat
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.utility.extension.toInstalledItem
|
||||
|
||||
|
||||
@@ -6,34 +6,35 @@ import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.looker.core.common.Constants
|
||||
import com.looker.core.common.Constants.NOTIFICATION_CHANNEL_INSTALL
|
||||
import com.looker.core.common.R
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.common.createNotificationChannel
|
||||
import com.looker.core.common.extension.notificationManager
|
||||
import com.looker.core.common.extension.percentBy
|
||||
import com.looker.core.common.extension.startSelf
|
||||
import com.looker.core.common.extension.stopForegroundCompat
|
||||
import com.looker.core.common.extension.toPendingIntent
|
||||
import com.looker.core.common.extension.updateAsMutable
|
||||
import com.looker.core.common.log
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.core.datastore.model.InstallerType
|
||||
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 com.looker.installer.InstallManager
|
||||
import com.looker.installer.model.InstallState
|
||||
import com.looker.installer.model.installFrom
|
||||
import com.looker.installer.notification.createInstallNotification
|
||||
import com.looker.installer.notification.installNotification
|
||||
import com.looker.network.DataSize
|
||||
import com.looker.network.Downloader
|
||||
import com.looker.network.NetworkResponse
|
||||
import com.looker.network.validation.ValidationException
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -50,7 +51,7 @@ 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
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DownloadService : ConnectionService<DownloadService.Binder>() {
|
||||
@@ -174,7 +175,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
|
||||
)
|
||||
createNotificationChannel(
|
||||
id = NOTIFICATION_CHANNEL_INSTALL,
|
||||
name = getString(stringRes.install)
|
||||
name = getString(R.string.install)
|
||||
)
|
||||
|
||||
lifecycleScope.launch {
|
||||
@@ -378,7 +379,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
|
||||
}
|
||||
if (!started) {
|
||||
started = true
|
||||
startServiceCompat()
|
||||
startSelf()
|
||||
}
|
||||
val task = tasks.removeFirstOrNull() ?: return
|
||||
with(stateNotificationBuilder) {
|
||||
|
||||
@@ -2,17 +2,17 @@ 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.core.common.extension.calculateHash
|
||||
import com.looker.core.common.extension.getPackageArchiveInfoCompat
|
||||
import com.looker.core.common.extension.singleSignature
|
||||
import com.looker.core.common.extension.versionCodeCompat
|
||||
import com.looker.network.validation.FileValidator
|
||||
import com.looker.core.common.signature.Hash
|
||||
import com.looker.network.validation.invalid
|
||||
import com.looker.core.common.signature.verifyHash
|
||||
import com.looker.droidify.model.Release
|
||||
import java.io.File
|
||||
import com.looker.droidify.R.string as strings
|
||||
import com.looker.core.common.R.string as strings
|
||||
|
||||
class ReleaseFileValidator(
|
||||
private val context: Context,
|
||||
|
||||
@@ -15,16 +15,17 @@ 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.core.common.Constants
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.createNotificationChannel
|
||||
import com.looker.core.common.extension.getColorFromAttr
|
||||
import com.looker.core.common.extension.notificationManager
|
||||
import com.looker.core.common.extension.startSelf
|
||||
import com.looker.core.common.extension.stopForegroundCompat
|
||||
import com.looker.core.common.log
|
||||
import com.looker.core.common.result.Result
|
||||
import com.looker.core.common.sdkAbove
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.MainActivity
|
||||
import com.looker.droidify.database.Database
|
||||
@@ -32,8 +33,8 @@ 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 com.looker.network.DataSize
|
||||
import com.looker.network.percentBy
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -51,12 +52,9 @@ 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 com.looker.core.common.R as CommonR
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
import com.looker.core.common.R.style as styleRes
|
||||
import kotlinx.coroutines.Job as CoroutinesJob
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -70,16 +68,15 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
private const val MAX_UPDATE_NOTIFICATION = 5
|
||||
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
|
||||
|
||||
val syncState = MutableSharedFlow<State>()
|
||||
private val syncState = MutableSharedFlow<State>()
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
sealed class State(val name: String) {
|
||||
class Connecting(appName: String) : State(appName)
|
||||
|
||||
class Syncing(
|
||||
data class Connecting(val appName: String) : State(appName)
|
||||
data class Syncing(
|
||||
val appName: String,
|
||||
val stage: RepositoryUpdater.Stage,
|
||||
val read: DataSize,
|
||||
@@ -87,18 +84,6 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
) : 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)
|
||||
@@ -140,7 +125,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
handleNextTask(cancelledTask?.hasUpdates == true)
|
||||
if (request != SyncRequest.AUTO && started == Started.AUTO) {
|
||||
started = Started.MANUAL
|
||||
startServiceCompat()
|
||||
startSelf()
|
||||
handleSetStarted()
|
||||
currentTask?.lastState?.let { publishForegroundState(true, it) }
|
||||
}
|
||||
@@ -159,8 +144,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
}
|
||||
|
||||
suspend fun updateAllApps() {
|
||||
val skipSignature = settingsRepository.getInitial().ignoreSignature
|
||||
updateAllAppsInternal(skipSignature)
|
||||
updateAllAppsInternal()
|
||||
}
|
||||
|
||||
fun setUpdateNotificationBlocker(fragment: Fragment?) {
|
||||
@@ -213,7 +197,6 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
private val binder = Binder()
|
||||
override fun onBind(intent: Intent): Binder = binder
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
@@ -293,10 +276,10 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
Constants.NOTIFICATION_ID_SYNCING,
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING)
|
||||
.setSmallIcon(AndroidR.drawable.stat_sys_warning)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
|
||||
.getColorFromAttr(AndroidR.attr.colorPrimary).defaultColor
|
||||
.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.setContentTitle(getString(stringRes.could_not_sync_FORMAT, repository.name))
|
||||
.setContentText(description)
|
||||
@@ -307,10 +290,10 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
private val stateNotificationBuilder by lazy {
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING)
|
||||
.setSmallIcon(R.drawable.ic_sync)
|
||||
.setSmallIcon(CommonR.drawable.ic_sync)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
|
||||
.getColorFromAttr(AndroidR.attr.colorPrimary).defaultColor
|
||||
.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.addAction(
|
||||
0,
|
||||
@@ -385,7 +368,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
}
|
||||
|
||||
is State.Finish -> {}
|
||||
}
|
||||
}::class
|
||||
}.build()
|
||||
)
|
||||
}
|
||||
@@ -405,8 +388,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
handleUpdates(
|
||||
hasUpdates = hasUpdates,
|
||||
notifyUpdates = setting.notifyUpdate,
|
||||
autoUpdate = setting.autoUpdate,
|
||||
skipSignature = setting.ignoreSignature,
|
||||
autoUpdate = setting.autoUpdate
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -423,7 +405,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
}
|
||||
started = newStarted
|
||||
if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) {
|
||||
startServiceCompat()
|
||||
startSelf()
|
||||
handleSetStarted()
|
||||
}
|
||||
val initialState = State.Connecting(repository!!.name)
|
||||
@@ -487,8 +469,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
private suspend fun handleUpdates(
|
||||
hasUpdates: Boolean,
|
||||
notifyUpdates: Boolean,
|
||||
autoUpdate: Boolean,
|
||||
skipSignature: Boolean,
|
||||
autoUpdate: Boolean
|
||||
) {
|
||||
try {
|
||||
if (!hasUpdates) {
|
||||
@@ -499,16 +480,15 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
return
|
||||
}
|
||||
val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true
|
||||
val updates = Database.ProductAdapter.getUpdates(skipSignature)
|
||||
val updates = Database.ProductAdapter.getUpdates()
|
||||
if (!blocked && updates.isNotEmpty()) {
|
||||
if (notifyUpdates) displayUpdatesNotification(updates)
|
||||
if (autoUpdate) updateAllAppsInternal(skipSignature)
|
||||
if (autoUpdate) updateAllAppsInternal()
|
||||
}
|
||||
handleUpdates(
|
||||
hasUpdates = false,
|
||||
notifyUpdates = notifyUpdates,
|
||||
autoUpdate = autoUpdate,
|
||||
skipSignature = skipSignature,
|
||||
autoUpdate = autoUpdate
|
||||
)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
@@ -518,9 +498,10 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateAllAppsInternal(skipSignature: Boolean) {
|
||||
private suspend fun updateAllAppsInternal() {
|
||||
log("Check Running", "Syncing")
|
||||
Database.ProductAdapter
|
||||
.getUpdates(skipSignature)
|
||||
.getUpdates()
|
||||
// Update Droid-ify the last
|
||||
.sortedBy { if (it.packageName == packageName) 1 else -1 }
|
||||
.map {
|
||||
@@ -541,22 +522,23 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
}
|
||||
|
||||
private fun displayUpdatesNotification(productItems: List<ProductItem>) {
|
||||
fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback)
|
||||
notificationManager?.notify(
|
||||
Constants.NOTIFICATION_ID_UPDATES,
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_UPDATES)
|
||||
.setSmallIcon(R.drawable.ic_new_releases)
|
||||
.setSmallIcon(CommonR.drawable.ic_new_releases)
|
||||
.setContentTitle(getString(stringRes.new_updates_available))
|
||||
.setContentText(
|
||||
resources.getQuantityString(
|
||||
R.plurals.new_updates_DESC_FORMAT,
|
||||
CommonR.plurals.new_updates_DESC_FORMAT,
|
||||
productItems.size,
|
||||
productItems.size
|
||||
)
|
||||
)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
|
||||
.getColorFromAttr(AndroidR.attr.colorPrimary).defaultColor
|
||||
.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
@@ -568,7 +550,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
)
|
||||
)
|
||||
.setStyle(
|
||||
NotificationCompat.InboxStyle().also {
|
||||
NotificationCompat.InboxStyle().applyHack {
|
||||
for (productItem in productItems.take(MAX_UPDATE_NOTIFICATION)) {
|
||||
val builder = SpannableStringBuilder(productItem.name)
|
||||
builder.setSpan(
|
||||
@@ -578,7 +560,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
builder.append(' ').append(productItem.version)
|
||||
it.addLine(builder)
|
||||
addLine(builder)
|
||||
}
|
||||
if (productItems.size > MAX_UPDATE_NOTIFICATION) {
|
||||
val summary =
|
||||
@@ -586,11 +568,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
stringRes.plus_more_FORMAT,
|
||||
productItems.size - MAX_UPDATE_NOTIFICATION
|
||||
)
|
||||
if (SdkCheck.isNougat) {
|
||||
it.addLine(summary)
|
||||
} else {
|
||||
it.setSummaryText(summary)
|
||||
}
|
||||
if (SdkCheck.isNougat) addLine(summary) else setSummaryText(summary)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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>
|
||||
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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?>
|
||||
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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"
|
||||
@@ -1,43 +0,0 @@
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.looker.droidify.sync.common
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
val JsonParser = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
isLenient = true
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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")
|
||||
@@ -1,31 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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(),
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
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()
|
||||
@@ -1,18 +0,0 @@
|
||||
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(),
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,38 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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>>
|
||||
@@ -1,275 +0,0 @@
|
||||
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
|
||||
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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(),
|
||||
)
|
||||
@@ -11,8 +11,8 @@ 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.core.common.SdkCheck
|
||||
import com.looker.core.common.nullIfEmpty
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.ui.repository.RepositoryFragment
|
||||
import com.looker.droidify.utility.PackageItemResolver
|
||||
@@ -20,7 +20,7 @@ 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
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
|
||||
class MessageDialog() : DialogFragment() {
|
||||
companion object {
|
||||
|
||||
@@ -1,35 +1,20 @@
|
||||
package com.looker.droidify.ui.appDetail
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.*
|
||||
import android.content.pm.PermissionGroupInfo
|
||||
import android.content.pm.PermissionInfo
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.*
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.format.DateFormat
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.style.BulletSpan
|
||||
import android.text.style.ClickableSpan
|
||||
import android.text.style.RelativeSizeSpan
|
||||
import android.text.style.ReplacementSpan
|
||||
import android.text.style.TypefaceSpan
|
||||
import android.text.style.URLSpan
|
||||
import android.text.style.*
|
||||
import android.text.util.Linkify
|
||||
import android.view.Gravity
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextSwitcher
|
||||
import android.widget.TextView
|
||||
import android.view.*
|
||||
import android.widget.*
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.net.toUri
|
||||
@@ -40,13 +25,17 @@ import androidx.core.text.util.LinkifyCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil3.load
|
||||
import coil.load
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import com.google.android.material.materialswitch.MaterialSwitch
|
||||
import com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.looker.network.DataSize
|
||||
import com.looker.core.common.extension.*
|
||||
import com.looker.core.common.formatSize
|
||||
import com.looker.core.common.nullIfEmpty
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.content.ProductPreferences
|
||||
import com.looker.droidify.model.InstalledItem
|
||||
@@ -55,22 +44,8 @@ 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.network.DataSize
|
||||
import com.looker.droidify.network.percentBy
|
||||
import com.looker.droidify.utility.PackageItemResolver
|
||||
import com.looker.droidify.utility.common.extension.authentication
|
||||
import com.looker.droidify.utility.common.extension.copyToClipboard
|
||||
import com.looker.droidify.utility.common.extension.corneredBackground
|
||||
import com.looker.droidify.utility.common.extension.dp
|
||||
import com.looker.droidify.utility.common.extension.dpToPx
|
||||
import com.looker.droidify.utility.common.extension.getColorFromAttr
|
||||
import com.looker.droidify.utility.common.extension.getDrawableCompat
|
||||
import com.looker.droidify.utility.common.extension.getMutatedIcon
|
||||
import com.looker.droidify.utility.common.extension.inflate
|
||||
import com.looker.droidify.utility.common.extension.open
|
||||
import com.looker.droidify.utility.common.extension.setTextSizeScaled
|
||||
import com.looker.droidify.utility.common.nullIfEmpty
|
||||
import com.looker.droidify.utility.common.sdkName
|
||||
import com.looker.droidify.utility.extension.ImageUtils.icon
|
||||
import com.looker.droidify.utility.extension.android.Android
|
||||
import com.looker.droidify.utility.extension.resources.TypefaceExtra
|
||||
import com.looker.droidify.utility.extension.resources.sizeScaled
|
||||
@@ -88,8 +63,8 @@ import kotlin.math.PI
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
import com.google.android.material.R as MaterialR
|
||||
import com.looker.droidify.R.drawable as drawableRes
|
||||
import com.looker.droidify.R.string as stringRes
|
||||
import com.looker.core.common.R.drawable as drawableRes
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
|
||||
class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
StableRecyclerAdapter<AppDetailAdapter.ViewType, RecyclerView.ViewHolder>() {
|
||||
@@ -103,7 +78,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
fun onFavouriteClicked()
|
||||
fun onPreferenceChanged(preference: ProductPreference)
|
||||
fun onPermissionsClick(group: String?, permissions: List<String>)
|
||||
fun onScreenshotClick(position: Int)
|
||||
fun onScreenshotClick(screenshot: Product.Screenshot, parentView: ImageView)
|
||||
fun onReleaseClick(release: Release)
|
||||
fun onRequestAddRepository(address: String)
|
||||
fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean
|
||||
@@ -315,6 +290,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
is Product.Donate.Regular -> drawableRes.ic_donate
|
||||
is Product.Donate.Bitcoin -> drawableRes.ic_donate_bitcoin
|
||||
is Product.Donate.Litecoin -> drawableRes.ic_donate_litecoin
|
||||
is Product.Donate.Flattr -> drawableRes.ic_donate_flattr
|
||||
is Product.Donate.Liberapay -> drawableRes.ic_donate_liberapay
|
||||
is Product.Donate.OpenCollective -> drawableRes.ic_donate_opencollective
|
||||
}
|
||||
@@ -323,6 +299,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
is Product.Donate.Regular -> context.getString(stringRes.website)
|
||||
is Product.Donate.Bitcoin -> "Bitcoin"
|
||||
is Product.Donate.Litecoin -> "Litecoin"
|
||||
is Product.Donate.Flattr -> "Flattr"
|
||||
is Product.Donate.Liberapay -> "Liberapay"
|
||||
is Product.Donate.OpenCollective -> "Open Collective"
|
||||
}
|
||||
@@ -331,8 +308,12 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
is Product.Donate.Regular -> Uri.parse(donate.url)
|
||||
is Product.Donate.Bitcoin -> Uri.parse("bitcoin:${donate.address}")
|
||||
is Product.Donate.Litecoin -> Uri.parse("litecoin:${donate.address}")
|
||||
is Product.Donate.Flattr -> Uri.parse(
|
||||
"https://flattr.com/thing/${donate.id}"
|
||||
)
|
||||
|
||||
is Product.Donate.Liberapay -> Uri.parse(
|
||||
"https://liberapay.com/${donate.id}"
|
||||
"https://liberapay.com/~${donate.id}"
|
||||
)
|
||||
|
||||
is Product.Donate.OpenCollective -> Uri.parse(
|
||||
@@ -557,7 +538,6 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
val size = itemView.findViewById<TextView>(R.id.size)!!
|
||||
val signature = itemView.findViewById<TextView>(R.id.signature)!!
|
||||
val compatibility = itemView.findViewById<TextView>(R.id.compatibility)!!
|
||||
val targetSdk = itemView.findViewById<TextView>(R.id.target_sdk)!!
|
||||
|
||||
val statefulViews: Sequence<View>
|
||||
get() = sequenceOf(
|
||||
@@ -568,8 +548,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
added,
|
||||
size,
|
||||
signature,
|
||||
compatibility,
|
||||
targetSdk,
|
||||
compatibility
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1315,7 +1294,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
}
|
||||
}
|
||||
}
|
||||
holder.size.text = DataSize(product?.displayRelease?.size ?: 0).toString()
|
||||
holder.size.text = product?.displayRelease?.size?.formatSize()
|
||||
|
||||
holder.dev.setOnClickListener {
|
||||
product?.source?.let { link ->
|
||||
@@ -1363,10 +1342,12 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
)
|
||||
holder.progress.isIndeterminate = status.total == null
|
||||
if (status.total != null) {
|
||||
holder.progress.setProgressCompat(
|
||||
status.read.value percentBy status.total.value,
|
||||
true
|
||||
)
|
||||
holder.progress.progress =
|
||||
(
|
||||
holder.progress.max.toFloat() *
|
||||
status.read.value /
|
||||
status.total.value
|
||||
).roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1426,15 +1407,17 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
holder as ScreenShotViewHolder
|
||||
item as Item.ScreenshotItem
|
||||
holder.screenshotsRecycler.run {
|
||||
setHasFixedSize(true)
|
||||
isNestedScrollingEnabled = false
|
||||
clipToPadding = false
|
||||
setPadding(8.dp, 8.dp, 8.dp, 8.dp)
|
||||
layoutManager =
|
||||
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
|
||||
adapter = ScreenshotsAdapter(callbacks::onScreenshotClick).apply {
|
||||
setScreenshots(item.repository, item.packageName, item.screenshots)
|
||||
}
|
||||
adapter =
|
||||
ScreenshotsAdapter { screenshot, view ->
|
||||
callbacks.onScreenshotClick(screenshot, view)
|
||||
}.apply {
|
||||
setScreenshots(item.repository, item.packageName, item.screenshots)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1620,7 +1603,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
holder.version.text =
|
||||
context.getString(stringRes.version_FORMAT, item.release.version)
|
||||
|
||||
with(holder.status) {
|
||||
holder.status.apply {
|
||||
isVisible = installed || suggested
|
||||
setText(
|
||||
when {
|
||||
@@ -1631,15 +1614,14 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
)
|
||||
background = context.corneredBackground
|
||||
setPadding(15, 15, 15, 15)
|
||||
if (installed) {
|
||||
backgroundTintList =
|
||||
context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer)
|
||||
setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer))
|
||||
val (background, foreground) = if (installed) {
|
||||
MaterialR.attr.colorSecondaryContainer to MaterialR.attr.colorOnSecondaryContainer
|
||||
} else {
|
||||
backgroundTintList =
|
||||
context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer)
|
||||
setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnPrimaryContainer))
|
||||
MaterialR.attr.colorPrimaryContainer to MaterialR.attr.colorOnPrimaryContainer
|
||||
}
|
||||
backgroundTintList =
|
||||
context.getColorFromAttr(background)
|
||||
setTextColor(context.getColorFromAttr(foreground))
|
||||
}
|
||||
holder.source.text =
|
||||
context.getString(stringRes.provided_by_FORMAT, item.repository.name)
|
||||
@@ -1654,7 +1636,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
holder.dateFormat.format(item.release.added)
|
||||
}
|
||||
holder.added.text = dateFormat
|
||||
holder.size.text = DataSize(item.release.size).toString()
|
||||
holder.size.text = item.release.size.formatSize()
|
||||
holder.signature.isVisible =
|
||||
item.showSignature && item.release.signature.isNotEmpty()
|
||||
if (item.showSignature && item.release.signature.isNotEmpty()) {
|
||||
@@ -1683,44 +1665,35 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
|
||||
}
|
||||
holder.signature.text = builder
|
||||
}
|
||||
with(holder.compatibility) {
|
||||
isVisible = incompatibility != null || singlePlatform != null
|
||||
if (incompatibility != null) {
|
||||
setTextColor(context.getColorFromAttr(MaterialR.attr.colorError))
|
||||
text = when (incompatibility) {
|
||||
is Release.Incompatibility.MinSdk,
|
||||
is Release.Incompatibility.MaxSdk -> context.getString(
|
||||
stringRes.incompatible_with_FORMAT,
|
||||
Android.name
|
||||
)
|
||||
holder.compatibility.isVisible = incompatibility != null || singlePlatform != null
|
||||
if (incompatibility != null) {
|
||||
holder.compatibility.setTextColor(
|
||||
context.getColorFromAttr(MaterialR.attr.colorError)
|
||||
)
|
||||
holder.compatibility.text = when (incompatibility) {
|
||||
is Release.Incompatibility.MinSdk,
|
||||
is Release.Incompatibility.MaxSdk
|
||||
-> context.getString(
|
||||
stringRes.incompatible_with_FORMAT,
|
||||
Android.name
|
||||
)
|
||||
|
||||
is Release.Incompatibility.Platform -> context.getString(
|
||||
stringRes.incompatible_with_FORMAT,
|
||||
Android.primaryPlatform ?: context.getString(stringRes.unknown)
|
||||
)
|
||||
is Release.Incompatibility.Platform -> context.getString(
|
||||
stringRes.incompatible_with_FORMAT,
|
||||
Android.primaryPlatform ?: context.getString(stringRes.unknown)
|
||||
)
|
||||
|
||||
is Release.Incompatibility.Feature -> context.getString(
|
||||
stringRes.requires_FORMAT,
|
||||
incompatibility.feature
|
||||
)
|
||||
}
|
||||
} else if (singlePlatform != null) {
|
||||
setTextColor(context.getColorFromAttr(android.R.attr.textColorSecondary))
|
||||
text = context.getString(
|
||||
stringRes.only_compatible_with_FORMAT,
|
||||
singlePlatform,
|
||||
is Release.Incompatibility.Feature -> context.getString(
|
||||
stringRes.requires_FORMAT,
|
||||
incompatibility.feature
|
||||
)
|
||||
}
|
||||
}
|
||||
with(holder.targetSdk) {
|
||||
val sdkVersion = sdkName.getOrDefault(
|
||||
item.release.targetSdkVersion,
|
||||
context.getString(
|
||||
stringRes.label_unknown_sdk,
|
||||
item.release.targetSdkVersion,
|
||||
),
|
||||
} else if (singlePlatform != null) {
|
||||
holder.compatibility.setTextColor(
|
||||
context.getColorFromAttr(android.R.attr.textColorSecondary)
|
||||
)
|
||||
text = context.getString(stringRes.label_targets_sdk, sdkVersion)
|
||||
holder.compatibility.text =
|
||||
context.getString(stringRes.only_compatible_with_FORMAT, singlePlatform)
|
||||
}
|
||||
val enabled = status == Status.Idle
|
||||
holder.statefulViews.forEach { it.isEnabled = enabled }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.looker.droidify.ui.appDetail
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
@@ -9,6 +8,7 @@ import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
@@ -20,13 +20,16 @@ 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 coil.load
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.common.extension.getLauncherActivities
|
||||
import com.looker.core.common.extension.getMutatedIcon
|
||||
import com.looker.core.common.extension.isFirstItemVisible
|
||||
import com.looker.core.common.extension.isSystemApplication
|
||||
import com.looker.core.common.extension.systemBarsPadding
|
||||
import com.looker.core.common.extension.updateAsMutable
|
||||
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
|
||||
@@ -40,21 +43,17 @@ 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.ImageUtils.url
|
||||
import com.looker.droidify.utility.extension.screenActivity
|
||||
import com.looker.droidify.utility.extension.startUpdate
|
||||
import com.looker.installer.model.InstallState
|
||||
import com.looker.installer.model.isCancellable
|
||||
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
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
|
||||
@@ -90,7 +89,6 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
|
||||
|
||||
private val viewModel: AppDetailViewModel by viewModels()
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private var layoutManagerState: LinearLayoutManager.SavedState? = null
|
||||
|
||||
private var actions = Pair(emptySet<Action>(), null as Action?)
|
||||
@@ -101,7 +99,6 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
|
||||
|
||||
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,
|
||||
@@ -112,12 +109,11 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
|
||||
}
|
||||
)
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
detailAdapter = AppDetailAdapter(this@AppDetailFragment)
|
||||
mainActivity.onToolbarCreated(toolbar)
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
toolbar.menu.apply {
|
||||
Action.entries.forEach { action ->
|
||||
add(0, action.id, 0, action.adapterAction.titleResId)
|
||||
@@ -209,12 +205,10 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
|
||||
super.onDestroyView()
|
||||
recyclerView = null
|
||||
detailAdapter = null
|
||||
imageViewer = null
|
||||
|
||||
downloadConnection.unbind(requireContext())
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
@@ -353,20 +347,10 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
|
||||
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,
|
||||
viewModel.packageName,
|
||||
installed?.installedItem,
|
||||
products
|
||||
)
|
||||
}
|
||||
|
||||
@@ -452,27 +436,20 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
|
||||
.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
|
||||
override fun onScreenshotClick(screenshot: Product.Screenshot, parentView: ImageView) {
|
||||
val product = products
|
||||
.firstOrNull { (product, _) ->
|
||||
product.screenshots.find { it === screenshot }?.identifier != null
|
||||
}
|
||||
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()
|
||||
?: return
|
||||
val screenshots = product.first.screenshots
|
||||
val position = screenshots.indexOfFirst { screenshot.identifier == it.identifier }
|
||||
StfalconImageViewer
|
||||
.Builder(context, screenshots) { view, current ->
|
||||
view.load(current.url(product.second, viewModel.packageName))
|
||||
}
|
||||
.withStartPosition(position)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onReleaseClick(release: Release) {
|
||||
@@ -530,7 +507,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
|
||||
}
|
||||
|
||||
override fun onRequestAddRepository(address: String) {
|
||||
mainActivity.navigateAddRepository(address)
|
||||
screenActivity.navigateAddRepository(address)
|
||||
}
|
||||
|
||||
override fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean {
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
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.core.common.extension.asStateFlow
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.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 com.looker.installer.InstallManager
|
||||
import com.looker.installer.model.InstallState
|
||||
import com.looker.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
|
||||
@@ -69,31 +62,6 @@ class AppDetailViewModel @Inject constructor(
|
||||
)
|
||||
}.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
|
||||
}
|
||||
@@ -128,15 +96,6 @@ class AppDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
|
||||
@@ -5,71 +5,56 @@ import android.graphics.drawable.Drawable
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil3.asImage
|
||||
import coil3.dispose
|
||||
import coil3.load
|
||||
import coil3.request.placeholder
|
||||
import coil3.size.Scale
|
||||
import coil.dispose
|
||||
import coil.load
|
||||
import coil.size.Dimension
|
||||
import coil.size.Scale
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import com.looker.droidify.databinding.VideoButtonBinding
|
||||
import com.looker.droidify.graphics.PaddingDrawable
|
||||
import com.looker.core.common.extension.aspectRatio
|
||||
import com.looker.core.common.extension.authentication
|
||||
import com.looker.core.common.extension.camera
|
||||
import com.looker.core.common.extension.dp
|
||||
import com.looker.core.common.extension.dpToPx
|
||||
import com.looker.core.common.extension.getColorFromAttr
|
||||
import com.looker.core.common.extension.selectableBackground
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.utility.common.extension.aspectRatio
|
||||
import com.looker.droidify.utility.common.extension.authentication
|
||||
import com.looker.droidify.utility.common.extension.camera
|
||||
import com.looker.droidify.utility.common.extension.dp
|
||||
import com.looker.droidify.utility.common.extension.getColorFromAttr
|
||||
import com.looker.droidify.utility.common.extension.layoutInflater
|
||||
import com.looker.droidify.utility.common.extension.openLink
|
||||
import com.looker.droidify.utility.common.extension.selectableBackground
|
||||
import com.looker.droidify.graphics.PaddingDrawable
|
||||
import com.looker.droidify.utility.extension.ImageUtils.url
|
||||
import com.looker.droidify.widget.StableRecyclerAdapter
|
||||
import com.google.android.material.R as MaterialR
|
||||
import com.looker.droidify.R.dimen as dimenRes
|
||||
import com.looker.core.common.R.dimen as dimenRes
|
||||
|
||||
class ScreenshotsAdapter(private val onClick: (position: Int) -> Unit) :
|
||||
class ScreenshotsAdapter(private val onClick: (Product.Screenshot, ImageView) -> Unit) :
|
||||
StableRecyclerAdapter<ScreenshotsAdapter.ViewType, RecyclerView.ViewHolder>() {
|
||||
enum class ViewType { SCREENSHOT, VIDEO }
|
||||
enum class ViewType { SCREENSHOT }
|
||||
|
||||
private val items = mutableListOf<Item>()
|
||||
private val items = mutableListOf<Item.ScreenshotItem>()
|
||||
|
||||
private inner class VideoViewHolder(
|
||||
binding: VideoButtonBinding,
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
val button = binding.videoButton
|
||||
|
||||
init {
|
||||
with(button) {
|
||||
layoutParams = RecyclerView.LayoutParams(
|
||||
RecyclerView.LayoutParams.WRAP_CONTENT,
|
||||
150.dp,
|
||||
)
|
||||
setOnClickListener {
|
||||
val item = items[absoluteAdapterPosition] as Item.VideoItem
|
||||
it.context?.openLink(item.videoUrl)
|
||||
}
|
||||
private class ViewHolder(context: Context) :
|
||||
RecyclerView.ViewHolder(FrameLayout(context)) {
|
||||
val image: ShapeableImageView = object : ShapeableImageView(context) {
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
setMeasuredDimension(measuredWidth, measuredHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private inner class ScreenshotViewHolder(
|
||||
context: Context,
|
||||
) : RecyclerView.ViewHolder(FrameLayout(context)) {
|
||||
val image = ShapeableImageView(context)
|
||||
val placeholderColor = context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer)
|
||||
val radius = context.resources.getDimension(dimenRes.shape_small_corner)
|
||||
|
||||
val imageShapeModel = image.shapeAppearanceModel.toBuilder()
|
||||
.setAllCornerSizes(radius)
|
||||
.build()
|
||||
val cameraIcon = context.camera.apply { setTintList(placeholderColor) }
|
||||
val cameraIcon = context.camera
|
||||
.apply { setTintList(placeholderColor) }
|
||||
val placeholder: Drawable = PaddingDrawable(cameraIcon, 3f, context.aspectRatio)
|
||||
|
||||
init {
|
||||
with(image) {
|
||||
layout(0, 0, 0, 0)
|
||||
adjustViewBounds = true
|
||||
shapeAppearanceModel = imageShapeModel
|
||||
background = context.selectableBackground
|
||||
isFocusable = true
|
||||
@@ -84,14 +69,6 @@ class ScreenshotsAdapter(private val onClick: (position: Int) -> Unit) :
|
||||
marginEnd = radius.toInt()
|
||||
}
|
||||
foregroundGravity = Gravity.CENTER
|
||||
setOnClickListener {
|
||||
val position = if (items.any { it.viewType == ViewType.VIDEO }) {
|
||||
absoluteAdapterPosition - 1
|
||||
} else {
|
||||
absoluteAdapterPosition
|
||||
}
|
||||
onClick(position)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,73 +76,67 @@ class ScreenshotsAdapter(private val onClick: (position: Int) -> Unit) :
|
||||
fun setScreenshots(
|
||||
repository: Repository,
|
||||
packageName: String,
|
||||
screenshots: List<Product.Screenshot>,
|
||||
screenshots: List<Product.Screenshot>
|
||||
) {
|
||||
items.clear()
|
||||
items += screenshots.map {
|
||||
if (it.type == Product.Screenshot.Type.VIDEO) Item.VideoItem(it.path)
|
||||
else Item.ScreenshotItem(repository, packageName, it)
|
||||
}
|
||||
items += screenshots.map { Item.ScreenshotItem(repository, packageName, it) }
|
||||
notifyItemRangeInserted(0, screenshots.size)
|
||||
}
|
||||
|
||||
override val viewTypeClass: Class<ViewType> get() = ViewType::class.java
|
||||
override fun getItemCount(): Int = items.size
|
||||
override fun getItemEnumViewType(position: Int) = items[position].viewType
|
||||
override fun getItemDescriptor(position: Int): String = items[position].descriptor
|
||||
override val viewTypeClass: Class<ViewType>
|
||||
get() = ViewType::class.java
|
||||
|
||||
override fun getItemEnumViewType(position: Int): ViewType {
|
||||
return ViewType.SCREENSHOT
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: ViewType,
|
||||
viewType: ViewType
|
||||
): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
ViewType.VIDEO -> VideoViewHolder(VideoButtonBinding.inflate(parent.context.layoutInflater))
|
||||
ViewType.SCREENSHOT -> ScreenshotViewHolder(parent.context)
|
||||
return ViewHolder(parent.context).apply {
|
||||
image.setOnClickListener {
|
||||
onClick(
|
||||
items[absoluteAdapterPosition].screenshot,
|
||||
it as ImageView
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (getItemEnumViewType(position)) {
|
||||
ViewType.SCREENSHOT -> {
|
||||
holder as ScreenshotViewHolder
|
||||
val item = items[position] as Item.ScreenshotItem
|
||||
with(holder.image) {
|
||||
load(item.screenshot.url(context, item.repository, item.packageName)) {
|
||||
authentication(item.repository.authentication)
|
||||
scale(Scale.FILL)
|
||||
placeholder(holder.placeholder)
|
||||
error(holder.placeholder.asImage())
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun getItemDescriptor(position: Int): String = items[position].descriptor
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
ViewType.VIDEO -> {}
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
holder as ViewHolder
|
||||
val item = items[position]
|
||||
with(holder.image) {
|
||||
load(item.screenshot.url(item.repository, item.packageName)) {
|
||||
size(Dimension.Undefined, Dimension(150.dp.dpToPx.toInt()))
|
||||
scale(Scale.FIT)
|
||||
placeholder(holder.placeholder)
|
||||
error(holder.placeholder)
|
||||
authentication(item.repository.authentication)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
||||
super.onViewRecycled(holder)
|
||||
if (holder is ScreenshotViewHolder) holder.image.dispose()
|
||||
holder as ViewHolder
|
||||
holder.image.dispose()
|
||||
}
|
||||
|
||||
private sealed interface Item {
|
||||
|
||||
val descriptor: String
|
||||
val viewType: ViewType
|
||||
private sealed class Item {
|
||||
abstract val descriptor: String
|
||||
|
||||
class ScreenshotItem(
|
||||
val repository: Repository,
|
||||
val packageName: String,
|
||||
val screenshot: Product.Screenshot,
|
||||
) : Item {
|
||||
override val viewType: ViewType get() = ViewType.SCREENSHOT
|
||||
val screenshot: Product.Screenshot
|
||||
) : Item() {
|
||||
override val descriptor: String
|
||||
get() = "screenshot.${repository.id}.${screenshot.identifier}"
|
||||
}
|
||||
|
||||
class VideoItem(val videoUrl: String) : Item {
|
||||
override val viewType: ViewType get() = ViewType.VIDEO
|
||||
override val descriptor: String get() = "video"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.looker.droidify.ui.appDetail
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.looker.droidify.R.string as stringRes
|
||||
|
||||
fun shizukuDialog(
|
||||
context: Context,
|
||||
shizukuState: ShizukuState,
|
||||
openShizuku: () -> Unit,
|
||||
switchInstaller: () -> Unit
|
||||
) = with(MaterialAlertDialogBuilder(context)) {
|
||||
when {
|
||||
shizukuState.isNotAlive -> {
|
||||
setTitle(stringRes.error_shizuku_service_unavailable)
|
||||
setMessage(stringRes.error_shizuku_not_running_DESC)
|
||||
}
|
||||
|
||||
shizukuState.isNotGranted -> {
|
||||
setTitle(stringRes.error_shizuku_not_granted)
|
||||
setMessage(stringRes.error_shizuku_not_granted_DESC)
|
||||
}
|
||||
|
||||
shizukuState.isNotInstalled -> {
|
||||
setTitle(stringRes.error_shizuku_not_installed)
|
||||
setMessage(stringRes.error_shizuku_not_installed_DESC)
|
||||
}
|
||||
}
|
||||
setPositiveButton(stringRes.switch_to_default_installer) { _, _ ->
|
||||
switchInstaller()
|
||||
}
|
||||
setNeutralButton(stringRes.open_shizuku) { _, _ ->
|
||||
openShizuku()
|
||||
}
|
||||
setNegativeButton(stringRes.cancel, null)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.looker.droidify.ui.appList
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -10,44 +9,37 @@ import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil3.load
|
||||
import coil.load
|
||||
import com.google.android.material.R as MaterialR
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.core.common.extension.authentication
|
||||
import com.looker.core.common.extension.corneredBackground
|
||||
import com.looker.core.common.extension.dp
|
||||
import com.looker.core.common.extension.getColorFromAttr
|
||||
import com.looker.core.common.extension.inflate
|
||||
import com.looker.core.common.extension.setTextSizeScaled
|
||||
import com.looker.core.common.nullIfEmpty
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.utility.common.extension.authentication
|
||||
import com.looker.droidify.utility.common.extension.corneredBackground
|
||||
import com.looker.droidify.utility.common.extension.dp
|
||||
import com.looker.droidify.utility.common.extension.getColorFromAttr
|
||||
import com.looker.droidify.utility.common.extension.inflate
|
||||
import com.looker.droidify.utility.common.extension.setTextSizeScaled
|
||||
import com.looker.droidify.utility.common.log
|
||||
import com.looker.droidify.utility.common.nullIfEmpty
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.utility.extension.ImageUtils.icon
|
||||
import com.looker.droidify.utility.extension.resources.TypefaceExtra
|
||||
import com.looker.droidify.widget.CursorRecyclerAdapter
|
||||
import kotlin.system.measureTimeMillis
|
||||
import com.google.android.material.R as MaterialR
|
||||
|
||||
class AppListAdapter(
|
||||
private val source: AppListFragment.Source,
|
||||
private val onClick: (packageName: String) -> Unit,
|
||||
private val onClick: (ProductItem) -> Unit
|
||||
) : CursorRecyclerAdapter<AppListAdapter.ViewType, RecyclerView.ViewHolder>() {
|
||||
|
||||
enum class ViewType { PRODUCT, LOADING, EMPTY }
|
||||
|
||||
private inner class ProductViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private class ProductViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val name = itemView.findViewById<TextView>(R.id.name)!!
|
||||
val status = itemView.findViewById<TextView>(R.id.status)!!
|
||||
val summary = itemView.findViewById<TextView>(R.id.summary)!!
|
||||
val icon = itemView.findViewById<ShapeableImageView>(R.id.icon)!!
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener {
|
||||
log(measureTimeMillis { onClick(getPackageName(absoluteAdapterPosition)) }, "Bench")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LoadingViewHolder(context: Context) :
|
||||
@@ -55,14 +47,7 @@ class AppListAdapter(
|
||||
init {
|
||||
with(itemView as FrameLayout) {
|
||||
val progressBar = CircularProgressIndicator(context)
|
||||
progressBar.isIndeterminate = true
|
||||
addView(
|
||||
progressBar,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
).apply { gravity = Gravity.CENTER }
|
||||
)
|
||||
addView(progressBar)
|
||||
layoutParams = RecyclerView.LayoutParams(
|
||||
RecyclerView.LayoutParams.MATCH_PARENT,
|
||||
RecyclerView.LayoutParams.MATCH_PARENT
|
||||
@@ -91,14 +76,12 @@ class AppListAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
private val repositories: HashMap<Long, Repository> = HashMap()
|
||||
|
||||
fun updateRepos(repos: List<Repository>) {
|
||||
repos.forEach {
|
||||
repositories[it.id] = it
|
||||
var repositories: Map<Long, Repository> = emptyMap()
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
var emptyText: String = ""
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
@@ -128,31 +111,24 @@ class AppListAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPackageName(position: Int): String {
|
||||
return Database.ProductAdapter.transformPackageName(moveTo(position.coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
private fun getProductItem(position: Int): ProductItem {
|
||||
return Database.ProductAdapter.transformItem(moveTo(position.coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: ViewType,
|
||||
viewType: ViewType
|
||||
): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
ViewType.PRODUCT -> ProductViewHolder(parent.inflate(R.layout.product_item))
|
||||
ViewType.PRODUCT -> ProductViewHolder(parent.inflate(R.layout.product_item)).apply {
|
||||
itemView.setOnClickListener { onClick(getProductItem(absoluteAdapterPosition)) }
|
||||
}
|
||||
|
||||
ViewType.LOADING -> LoadingViewHolder(parent.context)
|
||||
ViewType.EMPTY -> EmptyViewHolder(parent.context)
|
||||
}
|
||||
}
|
||||
|
||||
private var updateBackground: ColorStateList? = null
|
||||
private var updateForeground: ColorStateList? = null
|
||||
private var installedBackground: ColorStateList? = null
|
||||
private var installedForeground: ColorStateList? = null
|
||||
private var defaultForeground: ColorStateList? = null
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (getItemEnumViewType(position)) {
|
||||
ViewType.PRODUCT -> {
|
||||
@@ -160,9 +136,9 @@ class AppListAdapter(
|
||||
val productItem = getProductItem(position)
|
||||
holder.name.text = productItem.name
|
||||
holder.summary.text = productItem.summary
|
||||
holder.summary.isVisible = productItem.summary.isNotEmpty()
|
||||
&& productItem.name != productItem.summary
|
||||
val repository = repositories[productItem.repositoryId]
|
||||
holder.summary.isVisible =
|
||||
productItem.summary.isNotEmpty() && productItem.name != productItem.summary
|
||||
val repository: Repository? = repositories[productItem.repositoryId]
|
||||
if (repository != null) {
|
||||
val iconUrl = productItem.icon(view = holder.icon, repository = repository)
|
||||
holder.icon.load(iconUrl) {
|
||||
@@ -179,38 +155,28 @@ class AppListAdapter(
|
||||
val isInstalled = productItem.installedVersion.nullIfEmpty() != null
|
||||
when {
|
||||
productItem.canUpdate -> {
|
||||
if (updateBackground == null) {
|
||||
updateBackground =
|
||||
context.getColorFromAttr(MaterialR.attr.colorTertiaryContainer)
|
||||
}
|
||||
if (updateForeground == null) {
|
||||
updateForeground =
|
||||
context.getColorFromAttr(MaterialR.attr.colorOnTertiaryContainer)
|
||||
}
|
||||
backgroundTintList = updateBackground
|
||||
setTextColor(updateForeground)
|
||||
backgroundTintList =
|
||||
context.getColorFromAttr(MaterialR.attr.colorTertiaryContainer)
|
||||
setTextColor(
|
||||
context.getColorFromAttr(MaterialR.attr.colorOnTertiaryContainer)
|
||||
)
|
||||
}
|
||||
|
||||
isInstalled -> {
|
||||
if (installedBackground == null) {
|
||||
installedBackground =
|
||||
context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer)
|
||||
}
|
||||
if (installedForeground == null) {
|
||||
installedForeground =
|
||||
context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer)
|
||||
}
|
||||
backgroundTintList = installedBackground
|
||||
setTextColor(installedForeground)
|
||||
backgroundTintList =
|
||||
context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer)
|
||||
setTextColor(
|
||||
context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
setPadding(0, 0, 0, 0)
|
||||
if (defaultForeground == null) {
|
||||
defaultForeground =
|
||||
context.getColorFromAttr(MaterialR.attr.colorOnBackground)
|
||||
}
|
||||
setTextColor(defaultForeground)
|
||||
setTextColor(
|
||||
holder.status.context.getColorFromAttr(
|
||||
MaterialR.attr.colorOnBackground
|
||||
)
|
||||
)
|
||||
background = null
|
||||
return@with
|
||||
}
|
||||
@@ -219,9 +185,9 @@ class AppListAdapter(
|
||||
6.dp.let { setPadding(it, it, it, it) }
|
||||
}
|
||||
val enabled = productItem.compatible || productItem.installedVersion.isNotEmpty()
|
||||
holder.name.isEnabled = enabled
|
||||
holder.status.isEnabled = enabled
|
||||
holder.summary.isEnabled = enabled
|
||||
sequenceOf(holder.name, holder.status, holder.summary).forEach {
|
||||
it.isEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
ViewType.LOADING -> {
|
||||
@@ -232,6 +198,6 @@ class AppListAdapter(
|
||||
holder as EmptyViewHolder
|
||||
holder.text.text = emptyText
|
||||
}
|
||||
}
|
||||
}::class
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,19 +14,19 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.looker.droidify.R
|
||||
import com.looker.core.common.Scroller
|
||||
import com.looker.core.common.R as CommonR
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
import com.looker.core.common.extension.dp
|
||||
import com.looker.core.common.extension.isFirstItemVisible
|
||||
import com.looker.core.common.extension.systemBarsMargin
|
||||
import com.looker.core.common.extension.systemBarsPadding
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.database.CursorOwner
|
||||
import com.looker.droidify.databinding.RecyclerViewWithFabBinding
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.utility.common.Scroller
|
||||
import com.looker.droidify.utility.common.extension.dp
|
||||
import com.looker.droidify.utility.common.extension.isFirstItemVisible
|
||||
import com.looker.droidify.utility.common.extension.systemBarsMargin
|
||||
import com.looker.droidify.utility.common.extension.systemBarsPadding
|
||||
import com.looker.droidify.utility.extension.mainActivity
|
||||
import com.looker.droidify.utility.extension.screenActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import com.looker.droidify.R.string as stringRes
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AppListFragment() : Fragment(), CursorOwner.Callback {
|
||||
@@ -46,7 +46,7 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
|
||||
val titleResId: Int,
|
||||
val sections: Boolean,
|
||||
val order: Boolean,
|
||||
val updateAll: Boolean,
|
||||
val updateAll: Boolean
|
||||
) {
|
||||
AVAILABLE(stringRes.available, true, true, false),
|
||||
INSTALLED(stringRes.installed, false, true, false),
|
||||
@@ -63,15 +63,14 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
|
||||
get() = requireArguments().getString(EXTRA_SOURCE)!!.let(Source::valueOf)
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
private lateinit var appListAdapter: AppListAdapter
|
||||
private var scroller: Scroller? = null
|
||||
private lateinit var recyclerViewAdapter: AppListAdapter
|
||||
private var shortAnimationDuration: Int = 0
|
||||
private var layoutManagerState: Parcelable? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = RecyclerViewWithFabBinding.inflate(inflater, container, false)
|
||||
|
||||
@@ -84,16 +83,18 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
|
||||
isMotionEventSplittingEnabled = false
|
||||
setHasFixedSize(true)
|
||||
recycledViewPool.setMaxRecycledViews(AppListAdapter.ViewType.PRODUCT.ordinal, 30)
|
||||
appListAdapter = AppListAdapter(source, mainActivity::navigateProduct)
|
||||
adapter = appListAdapter
|
||||
recyclerViewAdapter = AppListAdapter(source) {
|
||||
screenActivity.navigateProduct(it.packageName)
|
||||
}
|
||||
adapter = recyclerViewAdapter
|
||||
systemBarsPadding()
|
||||
}
|
||||
val fab = binding.scrollUp
|
||||
with(fab) {
|
||||
if (source.updateAll) {
|
||||
text = getString(R.string.update_all)
|
||||
text = getString(CommonR.string.update_all)
|
||||
setOnClickListener { viewModel.updateAll() }
|
||||
setIconResource(R.drawable.ic_download)
|
||||
setIconResource(CommonR.drawable.ic_download)
|
||||
alpha = 1f
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.showUpdateAllButton.collect {
|
||||
@@ -102,13 +103,11 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
|
||||
}
|
||||
systemBarsMargin(16.dp)
|
||||
} else {
|
||||
text = null
|
||||
setIconResource(R.drawable.arrow_up)
|
||||
text = ""
|
||||
setIconResource(CommonR.drawable.arrow_up)
|
||||
setOnClickListener {
|
||||
if (scroller == null) {
|
||||
scroller = Scroller(requireContext())
|
||||
}
|
||||
scroller!!.targetPosition = 0
|
||||
val scroller = Scroller(requireContext())
|
||||
scroller.targetPosition = 0
|
||||
recyclerView.layoutManager?.startSmoothScroll(scroller)
|
||||
}
|
||||
alpha = 0f
|
||||
@@ -139,7 +138,7 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
launch {
|
||||
viewModel.reposStream.collect { repos ->
|
||||
appListAdapter.updateRepos(repos)
|
||||
recyclerViewAdapter.repositories = repos.associateBy { it.id }
|
||||
}
|
||||
}
|
||||
launch {
|
||||
@@ -161,13 +160,12 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
|
||||
super.onDestroyView()
|
||||
viewModel.syncConnection.unbind(requireContext())
|
||||
_binding = null
|
||||
scroller = null
|
||||
mainActivity.cursorOwner.detach(this)
|
||||
screenActivity.cursorOwner.detach(this)
|
||||
}
|
||||
|
||||
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {
|
||||
appListAdapter.cursor = cursor
|
||||
appListAdapter.emptyText = when {
|
||||
recyclerViewAdapter.cursor = cursor
|
||||
recyclerViewAdapter.emptyText = when {
|
||||
cursor == null -> ""
|
||||
viewModel.searchQuery.value.isNotEmpty() -> {
|
||||
getString(stringRes.no_matching_applications_found)
|
||||
@@ -199,7 +197,7 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
|
||||
|
||||
private fun updateRequest() {
|
||||
if (view != null) {
|
||||
mainActivity.cursorOwner.attach(this, viewModel.request(source))
|
||||
screenActivity.cursorOwner.attach(this, viewModel.request(source))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,52 +2,40 @@ package com.looker.droidify.ui.appList
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.looker.droidify.database.CursorOwner
|
||||
import com.looker.droidify.database.CursorOwner.Request.Available
|
||||
import com.looker.droidify.database.CursorOwner.Request.Installed
|
||||
import com.looker.droidify.database.CursorOwner.Request.Updates
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.get
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.core.common.extension.asStateFlow
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.core.datastore.model.SortOrder
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.model.ProductItem.Section.All
|
||||
import com.looker.droidify.database.CursorOwner
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import com.looker.droidify.utility.common.extension.asStateFlow
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flatMapConcat
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AppListViewModel
|
||||
@Inject constructor(
|
||||
settingsRepository: SettingsRepository,
|
||||
settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val skipSignatureStream = settingsRepository
|
||||
.get { ignoreSignature }
|
||||
.asStateFlow(false)
|
||||
|
||||
val sortOrderFlow = settingsRepository
|
||||
.get { sortOrder }
|
||||
.asStateFlow(SortOrder.UPDATED)
|
||||
|
||||
val reposStream = Database.RepositoryAdapter
|
||||
.getAllStream()
|
||||
.asStateFlow(emptyList())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val showUpdateAllButton = skipSignatureStream.flatMapConcat { skip ->
|
||||
Database.ProductAdapter
|
||||
.getUpdatesStream(skip)
|
||||
.map { it.isNotEmpty() }
|
||||
}.asStateFlow(false)
|
||||
val showUpdateAllButton = Database.ProductAdapter
|
||||
.getUpdatesStream()
|
||||
.map { it.isNotEmpty() }
|
||||
.asStateFlow(false)
|
||||
|
||||
val sortOrderFlow = settingsRepository.get { sortOrder }
|
||||
.asStateFlow(SortOrder.UPDATED)
|
||||
|
||||
private val sections = MutableStateFlow<ProductItem.Section>(All)
|
||||
|
||||
@@ -63,23 +51,22 @@ class AppListViewModel
|
||||
|
||||
fun request(source: AppListFragment.Source): CursorOwner.Request {
|
||||
return when (source) {
|
||||
AppListFragment.Source.AVAILABLE -> Available(
|
||||
searchQuery = searchQuery.value,
|
||||
section = sections.value,
|
||||
order = sortOrderFlow.value,
|
||||
AppListFragment.Source.AVAILABLE -> CursorOwner.Request.ProductsAvailable(
|
||||
searchQuery.value,
|
||||
sections.value,
|
||||
sortOrderFlow.value
|
||||
)
|
||||
|
||||
AppListFragment.Source.INSTALLED -> Installed(
|
||||
searchQuery = searchQuery.value,
|
||||
section = sections.value,
|
||||
order = sortOrderFlow.value,
|
||||
AppListFragment.Source.INSTALLED -> CursorOwner.Request.ProductsInstalled(
|
||||
searchQuery.value,
|
||||
sections.value,
|
||||
sortOrderFlow.value
|
||||
)
|
||||
|
||||
AppListFragment.Source.UPDATES -> Updates(
|
||||
searchQuery = searchQuery.value,
|
||||
section = sections.value,
|
||||
order = sortOrderFlow.value,
|
||||
skipSignatureCheck = skipSignatureStream.value,
|
||||
AppListFragment.Source.UPDATES -> CursorOwner.Request.ProductsUpdates(
|
||||
searchQuery.value,
|
||||
sections.value,
|
||||
sortOrderFlow.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,17 @@ import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil3.load
|
||||
import com.looker.droidify.databinding.ProductItemBinding
|
||||
import coil.load
|
||||
import com.google.android.material.R as MaterialR
|
||||
import com.looker.core.common.extension.authentication
|
||||
import com.looker.core.common.extension.corneredBackground
|
||||
import com.looker.core.common.extension.dp
|
||||
import com.looker.core.common.extension.getColorFromAttr
|
||||
import com.looker.core.common.nullIfEmpty
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.utility.common.extension.authentication
|
||||
import com.looker.droidify.utility.common.extension.corneredBackground
|
||||
import com.looker.droidify.utility.common.extension.dp
|
||||
import com.looker.droidify.utility.common.extension.getColorFromAttr
|
||||
import com.looker.droidify.utility.common.nullIfEmpty
|
||||
import com.google.android.material.R as MaterialR
|
||||
import com.looker.droidify.databinding.ProductItemBinding
|
||||
import com.looker.droidify.utility.extension.ImageUtils.icon
|
||||
|
||||
class FavouriteFragmentAdapter(
|
||||
private val onProductClick: (String) -> Unit
|
||||
|
||||
@@ -11,11 +11,11 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.utility.common.extension.systemBarsPadding
|
||||
import com.looker.core.common.R as CommonR
|
||||
import com.looker.core.common.extension.systemBarsPadding
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.ui.ScreenFragment
|
||||
import com.looker.droidify.utility.extension.mainActivity
|
||||
import com.looker.droidify.utility.extension.screenActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -43,7 +43,7 @@ class FavouritesFragment : ScreenFragment() {
|
||||
isVerticalScrollBarEnabled = false
|
||||
setHasFixedSize(true)
|
||||
recyclerViewAdapter =
|
||||
FavouriteFragmentAdapter { mainActivity.navigateProduct(it) }
|
||||
FavouriteFragmentAdapter { screenActivity.navigateProduct(it) }
|
||||
this.adapter = recyclerViewAdapter
|
||||
systemBarsPadding(includeFab = false)
|
||||
recyclerView = this
|
||||
@@ -68,12 +68,12 @@ class FavouritesFragment : ScreenFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
toolbar.title = getString(R.string.favourites)
|
||||
toolbar.title = getString(CommonR.string.favourites)
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
mainActivity.onToolbarCreated(toolbar)
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
package com.looker.droidify.ui.favourites
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.get
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.looker.core.common.extension.asStateFlow
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.utility.common.extension.asStateFlow
|
||||
import com.looker.droidify.database.Database
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class FavouritesViewModel @Inject constructor(
|
||||
settingsRepository: SettingsRepository,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val favouriteApps: StateFlow<List<List<Product>>> =
|
||||
@@ -25,4 +27,9 @@ class FavouritesViewModel @Inject constructor(
|
||||
}
|
||||
}.asStateFlow(emptyList())
|
||||
|
||||
fun updateFavourites(packageName: String) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.toggleFavourites(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user