Reorganize and group modules
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,52 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = sdk.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = sdk.versions.minSdk.get().toInt()
|
||||
targetSdk = sdk.versions.targetSdk.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
}
|
||||
namespace = "de.mm20.launcher2.applications"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.bundles.kotlin)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
implementation(libs.bundles.androidx.lifecycle)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
|
||||
implementation(libs.commons.text)
|
||||
|
||||
implementation(project(":core:base"))
|
||||
implementation(project(":core:ktx"))
|
||||
implementation(project(":core:compat"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.kts.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,4 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
/
|
||||
</manifest>
|
||||
@@ -0,0 +1,186 @@
|
||||
package de.mm20.launcher2.applications
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.LauncherActivityInfo
|
||||
import android.content.pm.LauncherApps
|
||||
import android.content.pm.ShortcutInfo
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Process
|
||||
import android.os.UserHandle
|
||||
import android.util.Log
|
||||
import de.mm20.launcher2.ktx.normalize
|
||||
import de.mm20.launcher2.search.data.LauncherApp
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.text.similarity.FuzzyScore
|
||||
import java.util.*
|
||||
|
||||
interface AppRepository {
|
||||
fun getAllInstalledApps(): Flow<List<LauncherApp>>
|
||||
fun getSuspendedPackages(): Flow<List<String>>
|
||||
fun search(query: String): Flow<ImmutableList<LauncherApp>>
|
||||
}
|
||||
|
||||
internal class AppRepositoryImpl(
|
||||
private val context: Context,
|
||||
) : AppRepository {
|
||||
|
||||
private val launcherApps =
|
||||
context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
|
||||
|
||||
private val installedApps = MutableStateFlow<List<LauncherApp>>(emptyList())
|
||||
private val suspendedPackages = MutableStateFlow<List<String>>(emptyList())
|
||||
|
||||
|
||||
private val profiles: List<UserHandle> =
|
||||
launcherApps.profiles.takeIf { it.isNotEmpty() } ?: listOf(Process.myUserHandle())
|
||||
|
||||
|
||||
init {
|
||||
launcherApps.registerCallback(object : LauncherApps.Callback() {
|
||||
override fun onPackagesUnavailable(
|
||||
packageNames: Array<out String>,
|
||||
user: UserHandle,
|
||||
replacing: Boolean
|
||||
) {
|
||||
installedApps.value =
|
||||
installedApps.value.filter { !packageNames.contains(it.`package`) }
|
||||
}
|
||||
|
||||
override fun onPackageChanged(packageName: String, user: UserHandle) {
|
||||
val apps = installedApps.value.toMutableList()
|
||||
apps.removeAll { packageName == it.`package` }
|
||||
apps.addAll(getApplications(packageName))
|
||||
installedApps.value = apps
|
||||
}
|
||||
|
||||
override fun onPackagesAvailable(
|
||||
packageNames: Array<out String>,
|
||||
user: UserHandle,
|
||||
replacing: Boolean
|
||||
) {
|
||||
val apps = installedApps.value.toMutableList()
|
||||
for (packageName in packageNames) {
|
||||
apps.addAll(getApplications(packageName))
|
||||
}
|
||||
installedApps.value = apps
|
||||
}
|
||||
|
||||
override fun onPackageAdded(packageName: String, user: UserHandle) {
|
||||
Log.d("MM20", "App installed: $packageName")
|
||||
val apps = installedApps.value.toMutableList()
|
||||
apps.addAll(getApplications(packageName))
|
||||
installedApps.value = apps
|
||||
}
|
||||
|
||||
override fun onPackageRemoved(packageName: String, user: UserHandle) {
|
||||
installedApps.value =
|
||||
installedApps.value.filter { packageName != (it.`package`) || it.getUser() != user }
|
||||
}
|
||||
|
||||
override fun onShortcutsChanged(
|
||||
packageName: String,
|
||||
shortcuts: MutableList<ShortcutInfo>,
|
||||
user: UserHandle
|
||||
) {
|
||||
super.onShortcutsChanged(packageName, shortcuts, user)
|
||||
onPackageChanged(packageName, user)
|
||||
}
|
||||
|
||||
override fun onPackagesSuspended(packageNames: Array<out String>?, user: UserHandle?) {
|
||||
super.onPackagesSuspended(packageNames, user)
|
||||
packageNames ?: return
|
||||
suspendedPackages.value = suspendedPackages.value + packageNames
|
||||
}
|
||||
|
||||
override fun onPackagesUnsuspended(
|
||||
packageNames: Array<out String>?,
|
||||
user: UserHandle?
|
||||
) {
|
||||
super.onPackagesUnsuspended(packageNames, user)
|
||||
packageNames ?: return
|
||||
suspendedPackages.value =
|
||||
suspendedPackages.value.filter { packageNames.contains(it) }
|
||||
}
|
||||
|
||||
}, Handler(Looper.getMainLooper()))
|
||||
val apps = profiles.map { p ->
|
||||
launcherApps.getActivityList(null, p).mapNotNull { getApplication(it, p) }
|
||||
}.flatten()
|
||||
installedApps.value = apps
|
||||
}
|
||||
|
||||
|
||||
override fun getSuspendedPackages(): Flow<List<String>> {
|
||||
return suspendedPackages
|
||||
}
|
||||
|
||||
private fun getApplications(packageName: String): List<LauncherApp> {
|
||||
if (packageName == context.packageName) return emptyList()
|
||||
|
||||
return profiles.map { p ->
|
||||
launcherApps.getActivityList(packageName, p).mapNotNull { getApplication(it, p) }
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
|
||||
private fun getApplication(
|
||||
launcherActivityInfo: LauncherActivityInfo,
|
||||
profile: UserHandle
|
||||
): LauncherApp? {
|
||||
if (launcherActivityInfo.applicationInfo.packageName == context.packageName && !context.packageName.endsWith(
|
||||
".debug"
|
||||
)
|
||||
) return null
|
||||
return LauncherApp(context, launcherActivityInfo)
|
||||
}
|
||||
|
||||
override fun search(query: String): Flow<ImmutableList<LauncherApp>> = channelFlow {
|
||||
|
||||
installedApps.collectLatest { apps ->
|
||||
withContext(Dispatchers.Default) {
|
||||
val appResults = mutableListOf<LauncherApp>()
|
||||
if (query.isEmpty()) {
|
||||
appResults.addAll(apps)
|
||||
} else {
|
||||
appResults.addAll(apps.filter {
|
||||
matches(it.label, query)
|
||||
})
|
||||
|
||||
val componentName = ComponentName.unflattenFromString(query)
|
||||
getActivityByComponentName(componentName)?.let { appResults.add(it) }
|
||||
}
|
||||
|
||||
appResults.sort()
|
||||
|
||||
send(appResults.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAllInstalledApps(): Flow<List<LauncherApp>> {
|
||||
return installedApps
|
||||
}
|
||||
|
||||
private fun matches(label: String, query: String): Boolean {
|
||||
val normalizedLabel = label.normalize()
|
||||
val fuzzyScore = FuzzyScore(Locale.getDefault())
|
||||
return fuzzyScore.fuzzyScore(label, query) >= query.length * 1.5 ||
|
||||
fuzzyScore.fuzzyScore(normalizedLabel, query.normalize()) >= query.length * 1.5
|
||||
}
|
||||
|
||||
private fun getActivityByComponentName(componentName: ComponentName?): LauncherApp? {
|
||||
componentName ?: return null
|
||||
val intent = Intent().setComponent(componentName)
|
||||
val lai = launcherApps.resolveActivity(intent, Process.myUserHandle())
|
||||
return lai?.let {
|
||||
LauncherApp(context, lai)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.mm20.launcher2.applications
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val applicationsModule = module {
|
||||
single<AppRepository> { AppRepositoryImpl(androidContext()) }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.LauncherApps
|
||||
import android.os.UserManager
|
||||
import androidx.core.content.getSystemService
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import org.json.JSONObject
|
||||
|
||||
class LauncherAppSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as LauncherApp
|
||||
val json = JSONObject()
|
||||
json.put("package", searchable.`package`)
|
||||
json.put("activity", searchable.activity)
|
||||
json.put("user", searchable.userSerialNumber)
|
||||
return json.toString()
|
||||
}
|
||||
|
||||
override val typePrefix: String
|
||||
get() = "app"
|
||||
}
|
||||
|
||||
class LauncherAppDeserializer(val context: Context) : SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable? {
|
||||
val json = JSONObject(serialized)
|
||||
val launcherApps = context.getSystemService<LauncherApps>()!!
|
||||
val userManager = context.getSystemService<UserManager>()!!
|
||||
val userSerial = json.optLong("user")
|
||||
val user = userManager.getUserForSerialNumber(userSerial) ?: return null
|
||||
val pkg = json.getString("package")
|
||||
val intent = Intent().also {
|
||||
it.component = ComponentName(pkg, json.getString("activity"))
|
||||
}
|
||||
val launcherActivityInfo = launcherApps.resolveActivity(intent, user) ?: return null
|
||||
return LauncherApp(context, launcherActivityInfo)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.LauncherActivityInfo
|
||||
import android.content.pm.LauncherApps
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.AdaptiveIconDrawable
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.os.UserHandle
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import de.mm20.launcher2.applications.R
|
||||
import de.mm20.launcher2.compat.PackageManagerCompat
|
||||
import de.mm20.launcher2.icons.*
|
||||
import de.mm20.launcher2.ktx.getSerialNumber
|
||||
import de.mm20.launcher2.ktx.isAtLeastApiLevel
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class LauncherApp(
|
||||
val launcherActivityInfo: LauncherActivityInfo,
|
||||
override val label: String,
|
||||
val `package`: String,
|
||||
val activity: String,
|
||||
val flags: Int,
|
||||
val version: String?,
|
||||
internal val userSerialNumber: Long,
|
||||
override val labelOverride: String? = null,
|
||||
) : SavableSearchable {
|
||||
|
||||
constructor(context: Context, launcherActivityInfo: LauncherActivityInfo): this(
|
||||
launcherActivityInfo,
|
||||
label = launcherActivityInfo.label.toString(),
|
||||
`package` = launcherActivityInfo.applicationInfo.packageName,
|
||||
activity = launcherActivityInfo.name,
|
||||
flags = launcherActivityInfo.applicationInfo.flags,
|
||||
version = getPackageVersionName(context, launcherActivityInfo.applicationInfo.packageName),
|
||||
userSerialNumber = launcherActivityInfo.user.getSerialNumber(context)
|
||||
)
|
||||
|
||||
val isMainProfile = launcherActivityInfo.user == Process.myUserHandle()
|
||||
|
||||
override val domain: String = Domain
|
||||
override val preferDetailsOverLaunch: Boolean = false
|
||||
|
||||
override fun overrideLabel(label: String): LauncherApp {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
|
||||
override val key: String
|
||||
get() = if (isMainProfile) "${domain}://$`package`:$activity" else "${domain}://$`package`:$activity:${userSerialNumber}"
|
||||
|
||||
fun getUser(): UserHandle? {
|
||||
return launcherActivityInfo.user
|
||||
}
|
||||
|
||||
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = TintedIconLayer(
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_file_android)!!,
|
||||
scale = 0.5f,
|
||||
color = 0xff3dda84.toInt(),
|
||||
),
|
||||
backgroundLayer = ColorLayer(0xff3dda84.toInt())
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun loadIcon(
|
||||
context: Context,
|
||||
size: Int,
|
||||
themed: Boolean,
|
||||
): LauncherIcon? {
|
||||
try {
|
||||
val icon =
|
||||
withContext(Dispatchers.IO) {
|
||||
launcherActivityInfo.getIcon(context.resources.displayMetrics.densityDpi)
|
||||
|
||||
} ?: return null
|
||||
if (icon is AdaptiveIconDrawable) {
|
||||
if (themed && isAtLeastApiLevel(33) && icon.monochrome != null) {
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = TintedIconLayer(
|
||||
scale = 1f,
|
||||
icon = icon.monochrome!!,
|
||||
),
|
||||
backgroundLayer = ColorLayer()
|
||||
)
|
||||
}
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = icon.foreground?.let {
|
||||
StaticIconLayer(
|
||||
icon = it,
|
||||
scale = 1.5f,
|
||||
)
|
||||
} ?: TransparentLayer,
|
||||
backgroundLayer = icon.background?.let {
|
||||
StaticIconLayer(
|
||||
icon = it,
|
||||
scale = 1.5f,
|
||||
)
|
||||
} ?: TransparentLayer,
|
||||
)
|
||||
} else {
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = StaticIconLayer(
|
||||
icon = icon,
|
||||
scale = 1f,
|
||||
),
|
||||
backgroundLayer = TransparentLayer
|
||||
)
|
||||
}
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
val launcherApps = context.getSystemService<LauncherApps>()!!
|
||||
try {
|
||||
launcherApps.startMainActivity(
|
||||
ComponentName(`package`, activity),
|
||||
launcherActivityInfo.user,
|
||||
null,
|
||||
options
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
return false
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun getStoreDetails(context: Context): StoreLink? {
|
||||
val pm = context.packageManager
|
||||
return try {
|
||||
val installSourceInfo = PackageManagerCompat.getInstallSource(pm, `package`)
|
||||
getStoreLinkForInstaller(installSourceInfo.initiatingPackageName, `package`)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
companion object {
|
||||
private fun getStoreLinkForInstaller(
|
||||
installerPackage: String?,
|
||||
packageName: String?
|
||||
): StoreLink? {
|
||||
if (packageName == null) return null
|
||||
return when (installerPackage) {
|
||||
"de.amazon.mShop.android", "com.amazon.venezia" -> {
|
||||
StoreLink(
|
||||
"Amazon App Shop",
|
||||
"http://www.amazon.com/gp/mas/dl/android?p=${packageName}"
|
||||
)
|
||||
}
|
||||
"com.android.vending" -> {
|
||||
StoreLink(
|
||||
"Google Play Store",
|
||||
"https://play.google.com/store/apps/details?id=${packageName}"
|
||||
)
|
||||
}
|
||||
"org.fdroid.fdroid", "com.aurora.adroid" -> {
|
||||
StoreLink(
|
||||
"F-Droid",
|
||||
"https://f-droid.org/packages/${packageName}"
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun getPackageVersionName(context: Context, packageName: String): String? {
|
||||
return try {
|
||||
context.packageManager.getPackageInfo(packageName, 0).versionName
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
const val Domain = "app"
|
||||
}
|
||||
}
|
||||
|
||||
data class StoreLink(
|
||||
val label: String,
|
||||
val url: String
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,51 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = sdk.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = sdk.versions.minSdk.get().toInt()
|
||||
targetSdk = sdk.versions.targetSdk.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
}
|
||||
namespace = "de.mm20.launcher2.appshortcuts"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.bundles.kotlin)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
|
||||
implementation(libs.commons.text)
|
||||
|
||||
implementation(project(":data:applications"))
|
||||
implementation(project(":core:permissions"))
|
||||
implementation(project(":core:base"))
|
||||
implementation(project(":core:ktx"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package de.mm20.launcher2.appshortcuts
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.LauncherActivityInfo
|
||||
import android.content.pm.LauncherApps
|
||||
import android.content.pm.ShortcutInfo
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Process
|
||||
import android.os.UserHandle
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
import de.mm20.launcher2.ktx.normalize
|
||||
import de.mm20.launcher2.permissions.PermissionGroup
|
||||
import de.mm20.launcher2.permissions.PermissionsManager
|
||||
import de.mm20.launcher2.search.data.AppShortcut
|
||||
import de.mm20.launcher2.search.data.LauncherApp
|
||||
import de.mm20.launcher2.search.data.LauncherShortcut
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.text.similarity.FuzzyScore
|
||||
import java.util.Locale
|
||||
|
||||
interface AppShortcutRepository {
|
||||
|
||||
fun search(query: String): Flow<ImmutableList<AppShortcut>>
|
||||
suspend fun getShortcutsForActivity(
|
||||
launcherActivityInfo: LauncherActivityInfo,
|
||||
count: Int = 5
|
||||
): List<LauncherShortcut>
|
||||
|
||||
suspend fun getShortcutsConfigActivities(): List<LauncherApp>
|
||||
|
||||
fun removePinnedShortcut(shortcut: LauncherShortcut)
|
||||
}
|
||||
|
||||
internal class AppShortcutRepositoryImpl(
|
||||
private val context: Context,
|
||||
private val permissionsManager: PermissionsManager,
|
||||
) : AppShortcutRepository {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + Job())
|
||||
|
||||
override suspend fun getShortcutsForActivity(
|
||||
launcherActivityInfo: LauncherActivityInfo,
|
||||
count: Int,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val launcherApps = context.getSystemService<LauncherApps>()!!
|
||||
if (!launcherApps.hasShortcutHostPermission()) return@withContext emptyList()
|
||||
val query = LauncherApps.ShortcutQuery()
|
||||
.setPackage(launcherActivityInfo.applicationInfo.packageName)
|
||||
.setQueryFlags(LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC or LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST)
|
||||
val shortcuts = try {
|
||||
launcherApps.getShortcuts(query, launcherActivityInfo.user)
|
||||
} catch (e: IllegalStateException) {
|
||||
emptyList()
|
||||
}
|
||||
val appShortcuts = mutableListOf<LauncherShortcut>()
|
||||
appShortcuts.addAll(shortcuts
|
||||
?.let {
|
||||
if (it.size > count) it.subList(0, count)
|
||||
else it
|
||||
}
|
||||
?.map {
|
||||
LauncherShortcut(
|
||||
context,
|
||||
it,
|
||||
)
|
||||
} ?: emptyList())
|
||||
appShortcuts
|
||||
}
|
||||
|
||||
override fun search(query: String) = channelFlow<ImmutableList<AppShortcut>> {
|
||||
if (query.length < 3) {
|
||||
send(persistentListOf())
|
||||
return@channelFlow
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!permissionsManager.checkPermissionOnce(PermissionGroup.AppShortcuts)) {
|
||||
send(persistentListOf())
|
||||
return@withContext
|
||||
}
|
||||
|
||||
|
||||
shortcutChangeEmitter.collectLatest {
|
||||
val launcherApps =
|
||||
context.getSystemService<LauncherApps>() ?: return@collectLatest send(
|
||||
persistentListOf()
|
||||
)
|
||||
|
||||
val shortcutQuery = LauncherApps.ShortcutQuery()
|
||||
shortcutQuery.setQueryFlags(
|
||||
LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED or
|
||||
LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC or
|
||||
LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST or
|
||||
LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED or
|
||||
LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED_BY_ANY_LAUNCHER
|
||||
)
|
||||
val shortcuts = launcherApps.getShortcuts(shortcutQuery, Process.myUserHandle())
|
||||
?.filter {
|
||||
if (it.longLabel != null) {
|
||||
return@filter matches(it.longLabel.toString(), query)
|
||||
}
|
||||
if (it.shortLabel != null) {
|
||||
return@filter matches(it.shortLabel.toString(), query)
|
||||
}
|
||||
return@filter false
|
||||
} ?: emptyList()
|
||||
|
||||
val pm = context.packageManager
|
||||
|
||||
|
||||
send(
|
||||
shortcuts.mapNotNull {
|
||||
LauncherShortcut(
|
||||
context,
|
||||
it
|
||||
)
|
||||
}.toImmutableList()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val shortcutChangeEmitter = callbackFlow {
|
||||
send(Unit)
|
||||
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
|
||||
|
||||
val callback = object : LauncherApps.Callback() {
|
||||
override fun onPackageRemoved(packageName: String?, user: UserHandle?) {
|
||||
}
|
||||
|
||||
override fun onPackageAdded(packageName: String?, user: UserHandle?) {
|
||||
}
|
||||
|
||||
override fun onPackageChanged(packageName: String?, user: UserHandle?) {
|
||||
}
|
||||
|
||||
override fun onPackagesAvailable(
|
||||
packageNames: Array<out String>?,
|
||||
user: UserHandle?,
|
||||
replacing: Boolean
|
||||
) {
|
||||
}
|
||||
|
||||
override fun onPackagesUnavailable(
|
||||
packageNames: Array<out String>?,
|
||||
user: UserHandle?,
|
||||
replacing: Boolean
|
||||
) {
|
||||
}
|
||||
|
||||
override fun onShortcutsChanged(
|
||||
packageName: String,
|
||||
shortcuts: MutableList<ShortcutInfo>,
|
||||
user: UserHandle
|
||||
) {
|
||||
super.onShortcutsChanged(packageName, shortcuts, user)
|
||||
trySend(Unit)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
launcherApps.registerCallback(callback, Handler(Looper.getMainLooper()))
|
||||
|
||||
awaitClose {
|
||||
launcherApps.unregisterCallback(callback)
|
||||
}
|
||||
}.shareIn(scope, SharingStarted.WhileSubscribed(500), 1)
|
||||
|
||||
override fun removePinnedShortcut(shortcut: LauncherShortcut) {
|
||||
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
|
||||
if (!launcherApps.hasShortcutHostPermission()) return
|
||||
val pinnedShortcutsQuery = LauncherApps.ShortcutQuery().apply {
|
||||
setQueryFlags(LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED)
|
||||
}
|
||||
val userHandle = shortcut.launcherShortcut.userHandle
|
||||
val allPinned = launcherApps.getShortcuts(pinnedShortcutsQuery, userHandle)
|
||||
|
||||
if (allPinned == null) {
|
||||
Log.e("MM20", "Could not remove shortcut ${shortcut.key}: shortcut query returned null")
|
||||
return
|
||||
}
|
||||
|
||||
launcherApps.pinShortcuts(
|
||||
shortcut.launcherShortcut.`package`,
|
||||
allPinned.filter { it.id != shortcut.launcherShortcut.id }.map { it.id },
|
||||
userHandle
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getShortcutsConfigActivities(): List<LauncherApp> {
|
||||
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
|
||||
if (!launcherApps.hasShortcutHostPermission()) return emptyList()
|
||||
val results = mutableListOf<LauncherApp>()
|
||||
val profiles = launcherApps.profiles
|
||||
for (profile in profiles) {
|
||||
val activities = launcherApps.getShortcutConfigActivityList(null, profile)
|
||||
results.addAll(
|
||||
activities.map {
|
||||
LauncherApp(
|
||||
context, it
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
return results.sorted()
|
||||
}
|
||||
|
||||
|
||||
private fun matches(label: String, query: String): Boolean {
|
||||
val labelLatin = label.normalize()
|
||||
val fuzzyScore = FuzzyScore(Locale.getDefault())
|
||||
return fuzzyScore.fuzzyScore(label, query) >= query.length * 1.5 ||
|
||||
fuzzyScore.fuzzyScore(labelLatin, query.normalize()) >= query.length * 1.5
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package de.mm20.launcher2.appshortcuts
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.ShortcutIconResource
|
||||
import android.content.pm.LauncherApps
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.UserManager
|
||||
import androidx.core.content.getSystemService
|
||||
import de.mm20.launcher2.ktx.jsonObjectOf
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import de.mm20.launcher2.search.data.LauncherShortcut
|
||||
import de.mm20.launcher2.search.data.LegacyShortcut
|
||||
import org.json.JSONObject
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
|
||||
class LauncherShortcutSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as LauncherShortcut
|
||||
return jsonObjectOf(
|
||||
"packagename" to searchable.launcherShortcut.`package`,
|
||||
"id" to searchable.launcherShortcut.id,
|
||||
"user" to searchable.userSerialNumber,
|
||||
).toString()
|
||||
}
|
||||
|
||||
override val typePrefix: String
|
||||
get() = "shortcut"
|
||||
|
||||
}
|
||||
|
||||
class LauncherShortcutDeserializer(
|
||||
val context: Context
|
||||
) : SearchableDeserializer, KoinComponent {
|
||||
|
||||
override fun deserialize(serialized: String): SavableSearchable? {
|
||||
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
|
||||
if (!launcherApps.hasShortcutHostPermission()) return null
|
||||
else {
|
||||
val json = JSONObject(serialized)
|
||||
val packageName = json.getString("packagename")
|
||||
val id = json.getString("id")
|
||||
val userSerial = json.optLong("user")
|
||||
val query = LauncherApps.ShortcutQuery()
|
||||
query.setPackage(packageName)
|
||||
query.setQueryFlags(LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED or
|
||||
LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC or
|
||||
LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST or
|
||||
LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED or
|
||||
LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED_BY_ANY_LAUNCHER
|
||||
)
|
||||
query.setShortcutIds(mutableListOf(id))
|
||||
val userManager = context.getSystemService<UserManager>()!!
|
||||
val user = userManager.getUserForSerialNumber(userSerial) ?: return null
|
||||
val shortcuts = try {
|
||||
launcherApps.getShortcuts(query, user)
|
||||
} catch (e: IllegalStateException) {
|
||||
return null
|
||||
}
|
||||
val pm = context.packageManager
|
||||
val appName = try {
|
||||
pm.getApplicationInfo(packageName, 0).loadLabel(pm).toString()
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
return null
|
||||
}
|
||||
if (shortcuts == null || shortcuts.isEmpty()) {
|
||||
return null
|
||||
} else {
|
||||
val activity = shortcuts[0].activity
|
||||
return LauncherShortcut(
|
||||
context = context,
|
||||
launcherShortcut = shortcuts[0],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LegacyShortcutSerializer: SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as LegacyShortcut
|
||||
return jsonObjectOf(
|
||||
"label" to searchable.label,
|
||||
"intent" to searchable.intent.toUri(0),
|
||||
"iconResource" to searchable.iconResource?.let {
|
||||
jsonObjectOf(
|
||||
"package" to it.packageName,
|
||||
"resource" to it.resourceName,
|
||||
)
|
||||
}
|
||||
).toString()
|
||||
}
|
||||
|
||||
override val typePrefix: String
|
||||
get() = "legacyshortcut"
|
||||
}
|
||||
|
||||
class LegacyShortcutDeserializer(
|
||||
val context: Context
|
||||
): SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable {
|
||||
val json = JSONObject(serialized)
|
||||
val label = json.getString("label")
|
||||
val intent = Intent.parseUri(json.getString("intent"), 0)
|
||||
val iconResourceObj = json.optJSONObject("iconResource")
|
||||
val iconResource = iconResourceObj?.let {
|
||||
ShortcutIconResource().apply {
|
||||
packageName = iconResourceObj.getString("package")
|
||||
resourceName = iconResourceObj.getString("resource")
|
||||
}
|
||||
}
|
||||
|
||||
val packageName = intent.`package` ?: intent.component?.packageName
|
||||
|
||||
val appName = try {
|
||||
packageName?.let {
|
||||
context
|
||||
.packageManager
|
||||
.getApplicationInfo(it, 0)
|
||||
.loadLabel(context.packageManager)
|
||||
.toString()
|
||||
}
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
|
||||
return LegacyShortcut(
|
||||
intent = intent,
|
||||
label = label,
|
||||
iconResource = iconResource,
|
||||
appName = appName,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.mm20.launcher2.appshortcuts
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val appShortcutsModule = module {
|
||||
single<AppShortcutRepository> { AppShortcutRepositoryImpl(androidContext(), get()) }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.mm20.launcher2.appshortcuts.R
|
||||
import de.mm20.launcher2.icons.ColorLayer
|
||||
import de.mm20.launcher2.icons.StaticLauncherIcon
|
||||
import de.mm20.launcher2.icons.TintedIconLayer
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
|
||||
interface AppShortcut: SavableSearchable {
|
||||
|
||||
val appName: String?
|
||||
|
||||
override val preferDetailsOverLaunch: Boolean
|
||||
get() = false
|
||||
|
||||
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = TintedIconLayer(
|
||||
color = 0xFF3DDA84.toInt(),
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_file_android)!!,
|
||||
scale = 0.5f,
|
||||
),
|
||||
backgroundLayer = ColorLayer(0xFF3DDA84.toInt()),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromPinRequestIntent(context: Context, data: Intent): AppShortcut? {
|
||||
return LauncherShortcut.fromPinRequestIntent(context, data)
|
||||
?: LegacyShortcut.fromPinRequestIntent(context, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.LauncherApps
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ShortcutInfo
|
||||
import android.graphics.drawable.AdaptiveIconDrawable
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import de.mm20.launcher2.appshortcuts.R
|
||||
import de.mm20.launcher2.icons.*
|
||||
import de.mm20.launcher2.ktx.getSerialNumber
|
||||
import de.mm20.launcher2.ktx.isAtLeastApiLevel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Represents a modern (Android O+) launcher shortcut
|
||||
*/
|
||||
data class LauncherShortcut(
|
||||
val launcherShortcut: ShortcutInfo,
|
||||
override val appName: String?,
|
||||
internal val userSerialNumber: Long,
|
||||
override val labelOverride: String? = null,
|
||||
) : AppShortcut {
|
||||
|
||||
override val domain: String = Domain
|
||||
|
||||
constructor(
|
||||
context: Context,
|
||||
launcherShortcut: ShortcutInfo,
|
||||
): this(
|
||||
launcherShortcut = launcherShortcut,
|
||||
appName = try {
|
||||
context.packageManager.getApplicationInfo(launcherShortcut.`package`, 0)
|
||||
.loadLabel(context.packageManager).toString()
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
},
|
||||
userSerialNumber = launcherShortcut.userHandle.getSerialNumber(context)
|
||||
)
|
||||
|
||||
override val label: String
|
||||
get() = launcherShortcut.shortLabel?.toString() ?: ""
|
||||
|
||||
override fun overrideLabel(label: String): LauncherShortcut {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
|
||||
override val preferDetailsOverLaunch: Boolean = false
|
||||
|
||||
|
||||
val isMainProfile = launcherShortcut.userHandle == Process.myUserHandle()
|
||||
|
||||
override val key: String
|
||||
get() = if (isMainProfile) {
|
||||
"$domain://${launcherShortcut.`package`}/${launcherShortcut.id}"
|
||||
} else {
|
||||
"$domain://${launcherShortcut.`package`}/${launcherShortcut.id}:${userSerialNumber}"
|
||||
}
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
val launcherApps = context.getSystemService<LauncherApps>()!!
|
||||
try {
|
||||
launcherApps.startShortcut(launcherShortcut, null, options)
|
||||
} catch (e: IllegalStateException) {
|
||||
return false
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = TintedIconLayer(
|
||||
color = 0xFF3DDA84.toInt(),
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_file_android)!!,
|
||||
scale = 0.5f,
|
||||
),
|
||||
backgroundLayer = ColorLayer(0xFF3DDA84.toInt()),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun loadIcon(
|
||||
context: Context,
|
||||
size: Int,
|
||||
themed: Boolean,
|
||||
): LauncherIcon? {
|
||||
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
|
||||
val icon = withContext(Dispatchers.IO) {
|
||||
launcherApps.getShortcutIconDrawable(
|
||||
launcherShortcut,
|
||||
context.resources.displayMetrics.densityDpi
|
||||
)
|
||||
} ?: return null
|
||||
if (icon is AdaptiveIconDrawable) {
|
||||
if (themed && isAtLeastApiLevel(33) && icon.monochrome != null) {
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = TintedIconLayer(
|
||||
scale = 1f,
|
||||
icon = icon.monochrome!!,
|
||||
),
|
||||
backgroundLayer = ColorLayer()
|
||||
)
|
||||
}
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = icon.foreground?.let {
|
||||
StaticIconLayer(
|
||||
icon = it,
|
||||
scale = 1.5f,
|
||||
)
|
||||
} ?: TransparentLayer,
|
||||
backgroundLayer = icon.background?.let {
|
||||
StaticIconLayer(
|
||||
icon = it,
|
||||
scale = 1.5f,
|
||||
)
|
||||
} ?: TransparentLayer,
|
||||
)
|
||||
}
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = StaticIconLayer(
|
||||
icon = icon,
|
||||
scale = 1f
|
||||
),
|
||||
backgroundLayer = TransparentLayer
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromPinRequestIntent(context: Context, data: Intent): LauncherShortcut? {
|
||||
val launcherApps =
|
||||
context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
|
||||
val pinRequest = launcherApps.getPinItemRequest(data)
|
||||
val shortcutInfo = pinRequest?.shortcutInfo ?: return null
|
||||
if (!pinRequest.accept()) return null
|
||||
return LauncherShortcut(
|
||||
context,
|
||||
shortcutInfo,
|
||||
)
|
||||
}
|
||||
|
||||
const val Domain = "shortcut"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.ShortcutIconResource
|
||||
import android.graphics.drawable.AdaptiveIconDrawable
|
||||
import android.os.Bundle
|
||||
import de.mm20.launcher2.icons.*
|
||||
import de.mm20.launcher2.ktx.getDrawableOrNull
|
||||
import de.mm20.launcher2.ktx.isAtLeastApiLevel
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
|
||||
data class LegacyShortcut(
|
||||
val intent: Intent,
|
||||
override val label: String,
|
||||
override val appName: String?,
|
||||
val iconResource: ShortcutIconResource?,
|
||||
override val labelOverride: String? = null,
|
||||
) : AppShortcut {
|
||||
|
||||
override val domain = Domain
|
||||
override val key: String = "$domain://${intent.toUri(0)}"
|
||||
|
||||
override fun overrideLabel(label: String): LegacyShortcut {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
return context.tryStartActivity(intent, options)
|
||||
}
|
||||
|
||||
val packageName: String?
|
||||
get() = intent.`package` ?: intent.component?.packageName
|
||||
|
||||
override suspend fun loadIcon(context: Context, size: Int, themed: Boolean): LauncherIcon? {
|
||||
if (iconResource == null) return null
|
||||
val resources = context.packageManager.getResourcesForApplication(iconResource.packageName)
|
||||
val drawableId =
|
||||
resources.getIdentifier(iconResource.resourceName, "drawable", iconResource.packageName)
|
||||
if (drawableId == 0) return null
|
||||
val icon = resources.getDrawableOrNull(drawableId) ?: return null
|
||||
if (icon is AdaptiveIconDrawable) {
|
||||
if (themed && isAtLeastApiLevel(33) && icon.monochrome != null) {
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = TintedIconLayer(
|
||||
scale = 1f,
|
||||
icon = icon.monochrome!!,
|
||||
),
|
||||
backgroundLayer = ColorLayer()
|
||||
)
|
||||
}
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = icon.foreground?.let {
|
||||
StaticIconLayer(
|
||||
icon = it,
|
||||
scale = 1.5f,
|
||||
)
|
||||
} ?: TransparentLayer,
|
||||
backgroundLayer = icon.background?.let {
|
||||
StaticIconLayer(
|
||||
icon = it,
|
||||
scale = 1.5f,
|
||||
)
|
||||
} ?: TransparentLayer,
|
||||
)
|
||||
}
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = StaticIconLayer(
|
||||
icon = icon,
|
||||
scale = 1f
|
||||
),
|
||||
backgroundLayer = TransparentLayer
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val Domain = "legacyshortcut"
|
||||
|
||||
fun fromPinRequestIntent(context: Context, data: Intent): LegacyShortcut? {
|
||||
val intent: Intent? = data.extras?.getParcelable(Intent.EXTRA_SHORTCUT_INTENT)
|
||||
val name: String? = data.extras?.getString(Intent.EXTRA_SHORTCUT_NAME)
|
||||
val iconResource: ShortcutIconResource? =
|
||||
data.extras?.getParcelable(Intent.EXTRA_SHORTCUT_ICON_RESOURCE)
|
||||
|
||||
if (intent == null || name == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val packageName = intent.`package` ?: intent.component?.packageName
|
||||
|
||||
return LegacyShortcut(
|
||||
intent = intent,
|
||||
appName = packageName?.let {
|
||||
context.packageManager.getApplicationInfo(
|
||||
it, 0
|
||||
).loadLabel(context.packageManager).toString()
|
||||
},
|
||||
label = name,
|
||||
iconResource = iconResource
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,50 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = sdk.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = sdk.versions.minSdk.get().toInt()
|
||||
targetSdk = sdk.versions.targetSdk.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
namespace = "de.mm20.launcher2.calculator"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.bundles.kotlin)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
implementation(libs.bundles.androidx.lifecycle)
|
||||
|
||||
implementation(libs.mathparser)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
|
||||
implementation(project(":core:base"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.kts.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,4 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
/
|
||||
</manifest>
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.mm20.launcher2.calculator
|
||||
|
||||
import de.mm20.launcher2.search.data.Calculator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.mariuszgromada.math.mxparser.Expression
|
||||
|
||||
interface CalculatorRepository {
|
||||
fun search(query: String): Flow<Calculator?>
|
||||
}
|
||||
|
||||
class CalculatorRepositoryImpl : CalculatorRepository, KoinComponent {
|
||||
|
||||
|
||||
override fun search(query: String): Flow<Calculator?> = channelFlow {
|
||||
if (query.isBlank()) {
|
||||
send(null)
|
||||
return@channelFlow
|
||||
}
|
||||
|
||||
send(queryCalculator(query))
|
||||
}
|
||||
|
||||
private suspend fun queryCalculator(query: String): Calculator? {
|
||||
return when {
|
||||
query.matches(Regex("0x[0-9a-fA-F]+")) -> {
|
||||
val solution = query.substring(2).toIntOrNull(16) ?: run {
|
||||
return null
|
||||
}
|
||||
Calculator(term = query, solution = solution.toDouble())
|
||||
}
|
||||
|
||||
query.matches(Regex("0b[01]+")) -> {
|
||||
val solution = query.substring(2).toIntOrNull(2) ?: run {
|
||||
return null
|
||||
}
|
||||
Calculator(term = query, solution = solution.toDouble())
|
||||
}
|
||||
|
||||
query.matches(Regex("0[0-7]+")) -> {
|
||||
val solution = query.substring(1).toIntOrNull(8) ?: run {
|
||||
return null
|
||||
}
|
||||
Calculator(term = query, solution = solution.toDouble())
|
||||
}
|
||||
|
||||
else -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
val exp = Expression(query)
|
||||
if (exp.checkSyntax()) {
|
||||
Calculator(term = query, solution = exp.calculate())
|
||||
} else {
|
||||
val exp2 = Expression(query.replace(',', '.').replace(';', ','))
|
||||
if (exp2.checkSyntax()) {
|
||||
Calculator(term = query, solution = exp2.calculate())
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.mm20.launcher2.calculator
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val calculatorModule = module {
|
||||
single<CalculatorRepository> { CalculatorRepositoryImpl() }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import de.mm20.launcher2.search.Searchable
|
||||
import java.text.DecimalFormat
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class Calculator(
|
||||
val term: String,
|
||||
val solution: Double
|
||||
): Searchable {
|
||||
|
||||
val formattedString: String
|
||||
val formattedBinaryString: String
|
||||
val formattedHexString: String
|
||||
val formattedOctString: String
|
||||
|
||||
init {
|
||||
if (solution.isNaN()) {
|
||||
formattedString = "NaN"
|
||||
formattedOctString = "NaN"
|
||||
formattedBinaryString = "NaN"
|
||||
formattedHexString = "NaN"
|
||||
} else {
|
||||
val nf =
|
||||
if ((abs(solution) > 1e12 || abs(solution) < 1e-5) && solution != 0.0) DecimalFormat(
|
||||
"#.######E0"
|
||||
)
|
||||
else DecimalFormat("#,###.######")
|
||||
formattedString = nf.format(solution)
|
||||
var s = StringBuffer(solution.roundToInt().toString(2))
|
||||
while (s.length % 4 != 0) {
|
||||
s = s.insert(0, '0')
|
||||
}
|
||||
|
||||
for (i in s.length - 4 downTo 4 step 4) {
|
||||
s.insert(i, ' ')
|
||||
}
|
||||
formattedBinaryString = s.toString()
|
||||
|
||||
s = StringBuffer(solution.roundToInt().toString(8))
|
||||
while (s.length % 3 != 0) {
|
||||
s = s.insert(0, '0')
|
||||
}
|
||||
|
||||
for (i in s.length - 3 downTo 3 step 3) {
|
||||
s.insert(i, ' ')
|
||||
}
|
||||
formattedOctString = s.toString()
|
||||
|
||||
s = StringBuffer(solution.roundToInt().toString(16).uppercase(Locale.getDefault()))
|
||||
while (s.length % 2 != 0) {
|
||||
s = s.insert(0, '0')
|
||||
}
|
||||
|
||||
for (i in s.length - 2 downTo 2 step 2) {
|
||||
s.insert(i, ' ')
|
||||
}
|
||||
formattedHexString = s.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun getBeatifiedTerm(): String {
|
||||
if(term.matches(Regex("0x[0-9a-fA-F]+"))) {
|
||||
return term.substring(2).uppercase(Locale.ROOT) + "₁₆"
|
||||
}
|
||||
if(term.matches(Regex("0b[01]+"))) {
|
||||
return term.substring(2) + "₂"
|
||||
}
|
||||
if(term.matches(Regex("0[0-7]+"))) {
|
||||
return term.substring(1) + "₈"
|
||||
}
|
||||
return term.replace(Regex("\\s+"), "")
|
||||
.replace("pi", " \u03C0 ", ignoreCase = true)
|
||||
.replace("*", " \u00D7 ")
|
||||
.replace("-", " \u2212 ")
|
||||
.replace("/", " \u2215 ")
|
||||
.replace("+", " + ")
|
||||
.replace(Regex("&{1,2}"), " \u2227 ")
|
||||
.replace(Regex("\\|{1,2}"), " \u2228 ")
|
||||
.replace("!=", " \u2260 ")
|
||||
.replace("<>", " \u2260 ")
|
||||
.replace(">=", " \u2265 ")
|
||||
.replace("<=", " \u2264 ")
|
||||
.replace("=", " = ")
|
||||
.replace("<", " < ")
|
||||
.replace(">", " > ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,50 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = sdk.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = sdk.versions.minSdk.get().toInt()
|
||||
targetSdk = sdk.versions.targetSdk.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
namespace = "de.mm20.launcher2.calendar"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.bundles.kotlin)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
|
||||
implementation(libs.koin.android)
|
||||
|
||||
implementation(project(":core:ktx"))
|
||||
implementation(project(":core:base"))
|
||||
implementation(project(":core:permissions"))
|
||||
implementation(project(":libs:material-color-utilities"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,4 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,213 @@
|
||||
package de.mm20.launcher2.calendar
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.database.getStringOrNull
|
||||
import de.mm20.launcher2.permissions.PermissionGroup
|
||||
import de.mm20.launcher2.permissions.PermissionsManager
|
||||
import de.mm20.launcher2.search.data.CalendarEvent
|
||||
import de.mm20.launcher2.search.data.UserCalendar
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import java.util.Calendar
|
||||
|
||||
interface CalendarRepository {
|
||||
|
||||
fun search(query: String): Flow<ImmutableList<CalendarEvent>>
|
||||
fun getUpcomingEvents(
|
||||
excludeCalendars: List<Long>,
|
||||
excludeAllDayEvents: Boolean
|
||||
): Flow<List<CalendarEvent>>
|
||||
|
||||
suspend fun getCalendars(): List<UserCalendar>
|
||||
}
|
||||
|
||||
internal class CalendarRepositoryImpl(
|
||||
private val context: Context,
|
||||
private val permissionsManager: PermissionsManager,
|
||||
) : CalendarRepository {
|
||||
|
||||
override fun search(query: String): Flow<ImmutableList<CalendarEvent>> {
|
||||
if (query.isBlank() || query.length < 3) {
|
||||
return flow {
|
||||
emit(persistentListOf())
|
||||
}
|
||||
}
|
||||
|
||||
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
|
||||
return hasPermission.map {
|
||||
if (it) {
|
||||
val now = System.currentTimeMillis()
|
||||
queryCalendarEvents(
|
||||
query,
|
||||
intervalStart = now,
|
||||
intervalEnd = now + 14 * 24 * 60 * 60 * 1000L,
|
||||
).toImmutableList()
|
||||
} else {
|
||||
persistentListOf()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private suspend fun queryCalendarEvents(
|
||||
query: String,
|
||||
intervalStart: Long,
|
||||
intervalEnd: Long,
|
||||
limit: Int = 10,
|
||||
excludeAllDayEvents: Boolean = false,
|
||||
excludeCalendars: List<Long> = emptyList(),
|
||||
): List<CalendarEvent> {
|
||||
val results = withContext(Dispatchers.IO) {
|
||||
val results = mutableListOf<CalendarEvent>()
|
||||
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
|
||||
ContentUris.appendId(builder, intervalStart)
|
||||
ContentUris.appendId(builder, intervalEnd)
|
||||
val uri = builder.build()
|
||||
val projection = arrayOf(
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.TITLE,
|
||||
CalendarContract.Instances.BEGIN,
|
||||
CalendarContract.Instances.END,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
CalendarContract.Instances.DISPLAY_COLOR,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.CALENDAR_ID,
|
||||
CalendarContract.Instances.DESCRIPTION
|
||||
)
|
||||
val selection = mutableListOf<String>()
|
||||
if (query.isNotEmpty()) selection.add("${CalendarContract.Instances.TITLE} LIKE ?")
|
||||
if (excludeCalendars.isNotEmpty()) selection.add("${CalendarContract.Instances.CALENDAR_ID} NOT IN (${excludeCalendars.joinToString()})")
|
||||
if (excludeAllDayEvents) selection.add("${CalendarContract.Instances.ALL_DAY} = 0")
|
||||
val selArgs = if (query.isBlank()) null else arrayOf("%$query%")
|
||||
val sort =
|
||||
"${CalendarContract.Instances.BEGIN} ASC" + if (limit > -1) " LIMIT $limit" else ""
|
||||
val cursor = context.contentResolver.query(
|
||||
uri,
|
||||
projection,
|
||||
selection.joinToString(separator = " AND "),
|
||||
selArgs,
|
||||
sort
|
||||
) ?: return@withContext mutableListOf()
|
||||
val proj = arrayOf(
|
||||
CalendarContract.Attendees.EVENT_ID,
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
CalendarContract.Attendees.ATTENDEE_EMAIL
|
||||
)
|
||||
val s = "${CalendarContract.Attendees.ATTENDEE_NAME} COLLATE NOCASE ASC"
|
||||
while (cursor.moveToNext()) {
|
||||
val sel = "${CalendarContract.Attendees.EVENT_ID} = ${cursor.getLong(0)}"
|
||||
val cur = context.contentResolver.query(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
proj, sel, null, s
|
||||
) ?: return@withContext mutableListOf()
|
||||
val attendees = mutableListOf<String>()
|
||||
while (cur.moveToNext()) {
|
||||
attendees.add(
|
||||
cur.getStringOrNull(1).takeUnless { it.isNullOrBlank() }
|
||||
?: cur.getStringOrNull(2)
|
||||
?: continue
|
||||
)
|
||||
}
|
||||
cur.close()
|
||||
val allday = cursor.getInt(4) > 0
|
||||
val begin = cursor.getLong(2)
|
||||
|
||||
val tzOffset = if (allday) {
|
||||
Calendar.getInstance().timeZone.getOffset(begin)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val event = CalendarEvent(
|
||||
label = cursor.getStringOrNull(1) ?: "",
|
||||
id = cursor.getLong(0),
|
||||
color = cursor.getInt(5),
|
||||
startTime = begin - tzOffset,
|
||||
endTime = cursor.getLong(3) - tzOffset - if (allday) 1 else 0,
|
||||
allDay = allday,
|
||||
location = cursor.getStringOrNull(6) ?: "",
|
||||
attendees = attendees,
|
||||
description = cursor.getStringOrNull(8)
|
||||
?: "",
|
||||
calendar = cursor.getLong(7)
|
||||
)
|
||||
results.add(event)
|
||||
}
|
||||
cursor.close()
|
||||
return@withContext results
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
override fun getUpcomingEvents(
|
||||
excludeCalendars: List<Long>,
|
||||
excludeAllDayEvents: Boolean,
|
||||
): Flow<List<CalendarEvent>> = channelFlow {
|
||||
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
|
||||
hasPermission.collectLatest {
|
||||
if (it) {
|
||||
val now = System.currentTimeMillis()
|
||||
val end = now + 14 * 24 * 60 * 60 * 1000L
|
||||
val events = withContext(Dispatchers.IO) {
|
||||
queryCalendarEvents(
|
||||
query = "",
|
||||
intervalStart = now,
|
||||
intervalEnd = end,
|
||||
limit = 700,
|
||||
excludeAllDayEvents = excludeAllDayEvents,
|
||||
excludeCalendars = excludeCalendars
|
||||
)
|
||||
}
|
||||
send(events)
|
||||
} else {
|
||||
send(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCalendars(): List<UserCalendar> {
|
||||
if (!permissionsManager.checkPermissionOnce(PermissionGroup.Calendar)) return emptyList()
|
||||
return withContext(Dispatchers.IO) {
|
||||
val calendars = mutableListOf<UserCalendar>()
|
||||
val uri = CalendarContract.Calendars.CONTENT_URI
|
||||
val proj = arrayOf(
|
||||
CalendarContract.Calendars._ID,
|
||||
CalendarContract.Calendars.NAME,
|
||||
CalendarContract.Calendars.ACCOUNT_NAME,
|
||||
CalendarContract.Calendars.CALENDAR_COLOR,
|
||||
CalendarContract.Calendars.VISIBLE,
|
||||
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME,
|
||||
)
|
||||
val cursor = context.contentResolver.query(uri, proj, null, null, null)
|
||||
?: return@withContext emptyList()
|
||||
while (cursor.moveToNext()) {
|
||||
try {
|
||||
calendars.add(
|
||||
UserCalendar(
|
||||
id = cursor.getLong(0),
|
||||
name = cursor.getStringOrNull(5) ?: cursor.getStringOrNull(1) ?: "",
|
||||
owner = cursor.getStringOrNull(2) ?: "",
|
||||
color = cursor.getInt(3)
|
||||
)
|
||||
)
|
||||
} catch (e: NullPointerException) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
cursor.close()
|
||||
calendars.sortBy { it.owner }
|
||||
return@withContext calendars
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package de.mm20.launcher2.calendar
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.database.getStringOrNull
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import de.mm20.launcher2.search.data.CalendarEvent
|
||||
import org.json.JSONObject
|
||||
import java.util.*
|
||||
|
||||
class CalendarEventSerializer: SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as CalendarEvent
|
||||
val json = JSONObject()
|
||||
json.put("id", searchable.id)
|
||||
return json.toString()
|
||||
}
|
||||
|
||||
override val typePrefix: String
|
||||
get() = "calendar"
|
||||
}
|
||||
|
||||
class CalendarEventDeserializer(val context: Context): SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable? {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) return null
|
||||
val json = JSONObject(serialized)
|
||||
val id = json.getLong("id")
|
||||
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
|
||||
ContentUris.appendId(builder, System.currentTimeMillis())
|
||||
ContentUris.appendId(builder, System.currentTimeMillis() + 63072000000L)
|
||||
val uri = builder.build()
|
||||
val projection = arrayOf(
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.TITLE,
|
||||
CalendarContract.Instances.BEGIN,
|
||||
CalendarContract.Instances.END,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
CalendarContract.Instances.DISPLAY_COLOR,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.CALENDAR_ID,
|
||||
CalendarContract.Instances.DESCRIPTION
|
||||
)
|
||||
val selection = CalendarContract.Instances.EVENT_ID + " = ?"
|
||||
val selArgs = arrayOf(id.toString())
|
||||
val cursor = context.contentResolver.query(uri, projection, selection, selArgs, null)
|
||||
?: return null
|
||||
if (cursor.moveToNext()) {
|
||||
val title = cursor.getStringOrNull(1) ?: ""
|
||||
val begin = cursor.getLong(2)
|
||||
val end = cursor.getLong(3)
|
||||
val allday = cursor.getInt(4) != 0
|
||||
val color = cursor.getInt(5)
|
||||
val location = cursor.getStringOrNull(6)
|
||||
val calendar = cursor.getLong(7)
|
||||
val description = cursor.getStringOrNull(8)
|
||||
?: ""
|
||||
cursor.close()
|
||||
val proj = arrayOf(
|
||||
CalendarContract.Attendees.EVENT_ID,
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
CalendarContract.Attendees.ATTENDEE_EMAIL
|
||||
)
|
||||
val sel = "${CalendarContract.Attendees.EVENT_ID} = $id"
|
||||
val s = "${CalendarContract.Attendees.ATTENDEE_NAME} COLLATE NOCASE ASC"
|
||||
val cur = context.contentResolver.query(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
proj, sel, null, s
|
||||
) ?: return null
|
||||
val attendees = mutableListOf<String>()
|
||||
while (cur.moveToNext()) {
|
||||
attendees.add(
|
||||
cur.getStringOrNull(1).takeUnless { it.isNullOrBlank() }
|
||||
?: cur.getStringOrNull(2)
|
||||
?: continue
|
||||
)
|
||||
}
|
||||
cur.close()
|
||||
val tzOffset = if (allday) {
|
||||
Calendar.getInstance().timeZone.getOffset(begin)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
return CalendarEvent(
|
||||
label = title,
|
||||
id = id,
|
||||
color = color,
|
||||
startTime = begin - tzOffset,
|
||||
endTime = end - tzOffset - if (allday) 1 else 0,
|
||||
allDay = allday,
|
||||
location = location ?: "",
|
||||
attendees = attendees,
|
||||
description = description,
|
||||
calendar = calendar
|
||||
)
|
||||
}
|
||||
cursor.close()
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.mm20.launcher2.calendar
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val calendarModule = module {
|
||||
single<CalendarRepository> { CalendarRepositoryImpl(androidContext(), get()) }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import de.mm20.launcher2.icons.ColorLayer
|
||||
import de.mm20.launcher2.icons.StaticLauncherIcon
|
||||
import de.mm20.launcher2.icons.TextLayer
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
data class CalendarEvent(
|
||||
override val label: String,
|
||||
val id: Long,
|
||||
val color: Int,
|
||||
val startTime: Long,
|
||||
val endTime: Long,
|
||||
val allDay: Boolean,
|
||||
val location: String,
|
||||
val attendees: List<String>,
|
||||
val description: String,
|
||||
val calendar: Long,
|
||||
override val labelOverride: String? = null,
|
||||
) : SavableSearchable {
|
||||
|
||||
override val domain: String = Domain
|
||||
|
||||
override val key: String
|
||||
get() = "$domain://$id"
|
||||
|
||||
override val preferDetailsOverLaunch: Boolean = true
|
||||
|
||||
override fun overrideLabel(label: String): CalendarEvent {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
|
||||
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
|
||||
val df = SimpleDateFormat("dd")
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = TextLayer(
|
||||
text = df.format(startTime),
|
||||
color = color
|
||||
),
|
||||
backgroundLayer = ColorLayer(color)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getLaunchIntent(): Intent {
|
||||
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id)
|
||||
return Intent(Intent.ACTION_VIEW).setData(uri).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
return context.tryStartActivity(getLaunchIntent(), options)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "calendar"
|
||||
}
|
||||
}
|
||||
|
||||
data class UserCalendar(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val owner: String,
|
||||
val color: Int
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,49 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = sdk.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = sdk.versions.minSdk.get().toInt()
|
||||
targetSdk = sdk.versions.targetSdk.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
namespace = "de.mm20.launcher2.contacts"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.bundles.kotlin)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
|
||||
implementation(libs.koin.android)
|
||||
|
||||
implementation(project(":core:ktx"))
|
||||
implementation(project(":core:base"))
|
||||
implementation(project(":core:permissions"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,4 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
/
|
||||
</manifest>
|
||||
@@ -0,0 +1,67 @@
|
||||
package de.mm20.launcher2.contacts
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import de.mm20.launcher2.permissions.PermissionGroup
|
||||
import de.mm20.launcher2.permissions.PermissionsManager
|
||||
import de.mm20.launcher2.search.data.Contact
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
interface ContactRepository {
|
||||
fun search(query: String): Flow<ImmutableList<Contact>>
|
||||
}
|
||||
|
||||
internal class ContactRepositoryImpl(
|
||||
private val context: Context,
|
||||
private val permissionsManager: PermissionsManager
|
||||
) : ContactRepository {
|
||||
|
||||
override fun search(query: String): Flow<ImmutableList<Contact>> {
|
||||
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Contacts)
|
||||
|
||||
if (query.length < 3) {
|
||||
return flow {
|
||||
emit(persistentListOf())
|
||||
}
|
||||
}
|
||||
|
||||
return hasPermission.map {
|
||||
if (it) {
|
||||
queryContacts(query)
|
||||
} else {
|
||||
persistentListOf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryContacts(query: String): ImmutableList<Contact> {
|
||||
val results = withContext(Dispatchers.IO) {
|
||||
val proj = arrayOf(
|
||||
ContactsContract.RawContacts.CONTACT_ID,
|
||||
ContactsContract.RawContacts._ID
|
||||
)
|
||||
val sel = "${ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY} LIKE ? OR ${ContactsContract.RawContacts.DISPLAY_NAME_ALTERNATIVE} LIKE ? OR ${ContactsContract.RawContacts.PHONETIC_NAME} LIKE ?"
|
||||
val selArgs = arrayOf("%$query%", "%$query%", "%$query%")
|
||||
val cursor = context.contentResolver.query(
|
||||
ContactsContract.RawContacts.CONTENT_URI, proj, sel, selArgs, null
|
||||
) ?: return@withContext mutableListOf()
|
||||
//Maps raw contact ids to contact ids
|
||||
val contactMap = mutableMapOf<Long, MutableSet<Long>>()
|
||||
while (cursor.moveToNext()) {
|
||||
contactMap.getOrPut(cursor.getLong(0)) { mutableSetOf() }.add(cursor.getLong(1))
|
||||
}
|
||||
cursor.close()
|
||||
val results = mutableListOf<Contact>()
|
||||
for ((id, rawIds) in contactMap) {
|
||||
Contact.contactById(context, id, rawIds)?.let { results.add(it) }
|
||||
}
|
||||
results
|
||||
}
|
||||
return results.toImmutableList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.mm20.launcher2.contacts
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.mm20.launcher2.ktx.jsonObjectOf
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import de.mm20.launcher2.search.data.Contact
|
||||
import org.json.JSONObject
|
||||
|
||||
class ContactSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as Contact
|
||||
return jsonObjectOf(
|
||||
"id" to searchable.id
|
||||
).toString()
|
||||
}
|
||||
|
||||
override val typePrefix: String
|
||||
get() = "contact"
|
||||
}
|
||||
|
||||
class ContactDeserializer(val context: Context) : SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable? {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_CONTACTS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) return null
|
||||
val id = JSONObject(serialized).getLong("id")
|
||||
val rawContactsCursor = context.contentResolver.query(
|
||||
ContactsContract.RawContacts.CONTENT_URI,
|
||||
arrayOf(ContactsContract.RawContacts._ID),
|
||||
"${ContactsContract.RawContacts.CONTACT_ID} = ?",
|
||||
arrayOf(id.toString()),
|
||||
null
|
||||
) ?: return null
|
||||
val rawContacts = mutableSetOf<Long>()
|
||||
while (rawContactsCursor.moveToNext()) {
|
||||
rawContacts.add(rawContactsCursor.getLong(0))
|
||||
}
|
||||
rawContactsCursor.close()
|
||||
if (rawContacts.isEmpty()) return null
|
||||
|
||||
return Contact.contactById(context, id, rawContacts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.mm20.launcher2.contacts
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val contactsModule = module {
|
||||
single<ContactRepository> { ContactRepositoryImpl(androidContext(), get()) }
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import de.mm20.launcher2.icons.*
|
||||
import de.mm20.launcher2.ktx.asBitmap
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.Searchable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.URLEncoder
|
||||
|
||||
data class Contact(
|
||||
val id: Long,
|
||||
val firstName: String,
|
||||
val lastName: String,
|
||||
val displayName: String,
|
||||
val lookupKey: String,
|
||||
val phones: Set<ContactInfo>,
|
||||
val emails: Set<ContactInfo>,
|
||||
val telegram: Set<ContactInfo>,
|
||||
val whatsapp: Set<ContactInfo>,
|
||||
val postals: Set<ContactInfo>,
|
||||
override val labelOverride: String? = null
|
||||
) : Searchable, SavableSearchable {
|
||||
|
||||
override val domain: String = Domain
|
||||
override val key: String
|
||||
get() = "${Domain}://$id"
|
||||
override val label: String
|
||||
get() = "$firstName $lastName"
|
||||
|
||||
override fun overrideLabel(label: String): Contact {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
|
||||
override val preferDetailsOverLaunch: Boolean = true
|
||||
|
||||
val summary: String
|
||||
get() {
|
||||
return phones.union(emails).joinToString(separator = ", ") { it.label }
|
||||
}
|
||||
|
||||
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
|
||||
val iconText =
|
||||
if (firstName.isNotEmpty()) firstName[0].toString() else "" + if (lastName.isNotEmpty()) lastName[0].toString() else ""
|
||||
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = TextLayer(text = iconText, color = 0xFF2364AA.toInt()),
|
||||
backgroundLayer = ColorLayer(0xFF2364AA.toInt())
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun loadIcon(
|
||||
context: Context,
|
||||
size: Int,
|
||||
themed: Boolean,
|
||||
): LauncherIcon? {
|
||||
val contentResolver = context.contentResolver
|
||||
val bmp = withContext(Dispatchers.IO) {
|
||||
val uri =
|
||||
ContactsContract.Contacts.getLookupUri(id, lookupKey) ?: return@withContext null
|
||||
ContactsContract.Contacts.openContactPhotoInputStream(contentResolver, uri, false)
|
||||
?.asBitmap()
|
||||
} ?: return null
|
||||
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = StaticIconLayer(
|
||||
icon = bmp.toDrawable(context.resources),
|
||||
),
|
||||
backgroundLayer = ColorLayer(0xFF2364AA.toInt())
|
||||
)
|
||||
}
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
val intent = getLaunchIntent()
|
||||
return context.tryStartActivity(intent, options)
|
||||
}
|
||||
|
||||
private fun getLaunchIntent(): Intent {
|
||||
val uri =
|
||||
ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id)
|
||||
return Intent(Intent.ACTION_VIEW).setData(uri).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
internal fun contactById(context: Context, id: Long, rawIds: Set<Long>): Contact? {
|
||||
val s = "(" + rawIds.joinToString(separator = " OR ",
|
||||
transform = { "${ContactsContract.Data.RAW_CONTACT_ID} = $it" }) + ")" +
|
||||
" AND (${ContactsContract.Data.MIMETYPE} = \"${ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE}\"" +
|
||||
" OR ${ContactsContract.Data.MIMETYPE} = \"${ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE}\"" +
|
||||
" OR ${ContactsContract.Data.MIMETYPE} = \"${ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE}\"" +
|
||||
" OR ${ContactsContract.Data.MIMETYPE} = \"${ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE}\"" +
|
||||
" OR ${ContactsContract.Data.MIMETYPE} = \"vnd.android.cursor.item/vnd.org.telegram.messenger.android.profile\"" +
|
||||
" OR ${ContactsContract.Data.MIMETYPE} = \"vnd.android.cursor.item/vnd.com.whatsapp.profile\"" +
|
||||
")"
|
||||
val dataCursor = context.contentResolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
null, s, null, null
|
||||
) ?: return null
|
||||
val phones = mutableSetOf<ContactInfo>()
|
||||
val emails = mutableSetOf<ContactInfo>()
|
||||
val telegram = mutableSetOf<ContactInfo>()
|
||||
val whatsapp = mutableSetOf<ContactInfo>()
|
||||
val postals = mutableSetOf<ContactInfo>()
|
||||
var firstName = ""
|
||||
var lastName = ""
|
||||
var displayName = ""
|
||||
val mimeTypeColumn = dataCursor.getColumnIndex(ContactsContract.Data.MIMETYPE)
|
||||
val emailAddressColumn =
|
||||
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS)
|
||||
val numberColumn =
|
||||
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
val addressColumn =
|
||||
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS)
|
||||
val displayNameColumn =
|
||||
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME)
|
||||
val givenNameColumn =
|
||||
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME)
|
||||
val familyNameColumn =
|
||||
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME)
|
||||
val data1Column = dataCursor.getColumnIndex(ContactsContract.Data.DATA1)
|
||||
val data3Column = dataCursor.getColumnIndex(ContactsContract.Data.DATA3)
|
||||
val idColumn = dataCursor.getColumnIndex(ContactsContract.Data._ID)
|
||||
loop@ while (dataCursor.moveToNext()) {
|
||||
when (dataCursor.getStringOrNull(mimeTypeColumn)) {
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE ->
|
||||
dataCursor.getStringOrNull(emailAddressColumn)?.let {
|
||||
emails.add(ContactInfo(it, "mailto:$it"))
|
||||
}
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE ->
|
||||
dataCursor.getStringOrNull(numberColumn)?.let {
|
||||
val phone = it.replace(Regex("[^+0-9]"), "")
|
||||
phones.add(
|
||||
ContactInfo(
|
||||
phone,
|
||||
"tel:$phone"
|
||||
)
|
||||
)
|
||||
}
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE ->
|
||||
dataCursor.getStringOrNull(addressColumn)?.let {
|
||||
postals.add(
|
||||
ContactInfo(
|
||||
it.replace("\n", ", "),
|
||||
"geo:0,0?q=${URLEncoder.encode(it, "utf8")}"
|
||||
)
|
||||
)
|
||||
}
|
||||
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
|
||||
firstName = dataCursor.getStringOrNull(givenNameColumn) ?: ""
|
||||
lastName = dataCursor.getStringOrNull(familyNameColumn) ?: ""
|
||||
displayName = dataCursor.getStringOrNull(displayNameColumn) ?: ""
|
||||
}
|
||||
"vnd.android.cursor.item/vnd.org.telegram.messenger.android.profile" -> {
|
||||
val data1 = dataCursor.getStringOrNull(data1Column)
|
||||
?: continue@loop
|
||||
val data3 = dataCursor.getStringOrNull(data3Column)
|
||||
?: continue@loop
|
||||
telegram.add(
|
||||
ContactInfo(
|
||||
data3.substringAfterLast(" "),
|
||||
"tg:openmessage?user_id=$data1"
|
||||
)
|
||||
)
|
||||
}
|
||||
"vnd.android.cursor.item/vnd.com.whatsapp.profile" -> {
|
||||
val data1 = dataCursor.getStringOrNull(data1Column)
|
||||
?: continue@loop
|
||||
val dataId = dataCursor.getLong(idColumn)
|
||||
whatsapp.add(
|
||||
ContactInfo(
|
||||
"+${data1.substringBefore('@')}",
|
||||
Uri.withAppendedPath(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
dataId.toString()
|
||||
).toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
dataCursor.close()
|
||||
|
||||
val lookupKeyCursor = context.contentResolver.query(
|
||||
ContactsContract.Contacts.CONTENT_URI,
|
||||
arrayOf(ContactsContract.Contacts.LOOKUP_KEY),
|
||||
"${ContactsContract.Contacts._ID} = ?",
|
||||
arrayOf(id.toString()),
|
||||
null
|
||||
) ?: return null
|
||||
var lookUpKey = ""
|
||||
if (lookupKeyCursor.moveToNext()) {
|
||||
lookUpKey = lookupKeyCursor.getString(0)
|
||||
}
|
||||
lookupKeyCursor.close()
|
||||
|
||||
return Contact(
|
||||
id = id,
|
||||
emails = emails,
|
||||
phones = phones,
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
displayName = displayName,
|
||||
postals = postals,
|
||||
telegram = telegram,
|
||||
whatsapp = whatsapp,
|
||||
lookupKey = lookUpKey
|
||||
)
|
||||
}
|
||||
|
||||
const val Domain = "contact"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class ContactInfo(
|
||||
val label: String,
|
||||
val data: String
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,50 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = sdk.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = sdk.versions.minSdk.get().toInt()
|
||||
targetSdk = sdk.versions.targetSdk.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
namespace = "de.mm20.launcher2.currencies"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(libs.bundles.kotlin)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.work)
|
||||
|
||||
implementation(libs.okhttp)
|
||||
|
||||
implementation(project(":core:ktx"))
|
||||
implementation(project(":core:i18n"))
|
||||
implementation(project(":core:database"))
|
||||
implementation(project(":core:crashreporter"))
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,3 @@
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.mm20.launcher2.currencies
|
||||
|
||||
import de.mm20.launcher2.database.entities.CurrencyEntity
|
||||
|
||||
data class Currency(
|
||||
val symbol: String,
|
||||
val value: Double,
|
||||
val lastUpdate: Long
|
||||
) {
|
||||
constructor(entity: CurrencyEntity) : this(
|
||||
symbol = entity.symbol,
|
||||
value = entity.value,
|
||||
lastUpdate = entity.lastUpdate
|
||||
)
|
||||
|
||||
fun toDatabaseEntity(): CurrencyEntity {
|
||||
return CurrencyEntity(symbol, value, lastUpdate)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package de.mm20.launcher2.currencies
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import de.mm20.launcher2.database.AppDatabase
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class CurrencyRepository(
|
||||
private val context: Context,
|
||||
) {
|
||||
|
||||
fun enableCurrencyUpdateWorker() {
|
||||
val currencyWorker =
|
||||
PeriodicWorkRequest.Builder(ExchangeRateWorker::class.java, 60, TimeUnit.MINUTES)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
"ExchangeRates",
|
||||
ExistingPeriodicWorkPolicy.KEEP, currencyWorker
|
||||
)
|
||||
}
|
||||
|
||||
fun disableCurrencyUpdateWorker() {
|
||||
WorkManager.getInstance(context).cancelUniqueWork("ExchangeRates")
|
||||
}
|
||||
|
||||
suspend fun convertCurrency(
|
||||
fromCurrency: String,
|
||||
value: Double,
|
||||
toCurrency: String? = null
|
||||
): List<Pair<String, Double>> {
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val dao = AppDatabase.getInstance(context)
|
||||
.currencyDao()
|
||||
|
||||
val from = Currency(dao.getCurrency(fromCurrency) ?: return@withContext emptyList())
|
||||
|
||||
return@withContext if (toCurrency == null) {
|
||||
dao.getAllCurrencies().mapNotNull {
|
||||
val to = Currency(it)
|
||||
if (from.lastUpdate != to.lastUpdate) {
|
||||
Log.w("MM20", "Exchange rate update dates do not match: $fromCurrency, $it")
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (from.symbol == to.symbol) return@mapNotNull null
|
||||
to.symbol to value * to.value / from.value
|
||||
}
|
||||
} else {
|
||||
val to = Currency(dao.getCurrency(toCurrency) ?: return@withContext emptyList())
|
||||
if (from.lastUpdate != to.lastUpdate) {
|
||||
Log.w(
|
||||
"MM20",
|
||||
"Exchange rate update dates do not match: $fromCurrency, $toCurrency"
|
||||
)
|
||||
return@withContext emptyList()
|
||||
}
|
||||
listOf(toCurrency to value * to.value / from.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isValidCurrency(symbol: String): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
AppDatabase.getInstance(context).currencyDao().exists(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLastUpdate(symbol: String): Long {
|
||||
return withContext(Dispatchers.IO) {
|
||||
AppDatabase.getInstance(context).currencyDao().getLastUpdate(symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.mm20.launcher2.currencies
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.database.AppDatabase
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.w3c.dom.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
class ExchangeRateWorker(val context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
override fun doWork(): Result {
|
||||
Log.d("MM20", "Updating currency exchange rates")
|
||||
val httpClient = OkHttpClient()
|
||||
val request = Request.Builder()
|
||||
.url("https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml")
|
||||
.get()
|
||||
.build()
|
||||
try {
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(response.body?.byteStream()
|
||||
?: return Result.retry())
|
||||
val cubes = document.getElementsByTagName("Cube")
|
||||
val values = mutableListOf<Pair<String, Double>>()
|
||||
var timestamp = System.currentTimeMillis()
|
||||
values += "EUR" to 1.0
|
||||
for (i in 0 until cubes.length) {
|
||||
val cube = cubes.item(i) as? Element ?: continue
|
||||
if (cube.hasAttribute("currency")) {
|
||||
val symbol = cube.getAttribute("currency")
|
||||
val value = cube.getAttribute("rate").toDoubleOrNull() ?: continue
|
||||
values += symbol to value
|
||||
} else if (cube.hasAttribute("time")) {
|
||||
val date = cube.getAttribute("time")
|
||||
timestamp = SimpleDateFormat("yyyy-MM-dd").parse(date).time
|
||||
}
|
||||
}
|
||||
val currencies = values.map {
|
||||
Currency(
|
||||
symbol = it.first,
|
||||
value = it.second,
|
||||
lastUpdate = timestamp
|
||||
).toDatabaseEntity()
|
||||
}
|
||||
AppDatabase.getInstance(context).currencyDao().insertAll(currencies)
|
||||
return Result.success()
|
||||
} catch (e: Exception) {
|
||||
CrashReporter.logException(e)
|
||||
return Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,48 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = sdk.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = sdk.versions.minSdk.get().toInt()
|
||||
targetSdk = sdk.versions.targetSdk.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
namespace = "de.mm20.launcher2.data.customattrs"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.bundles.kotlin)
|
||||
implementation(libs.androidx.core)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
|
||||
implementation(project(":core:database"))
|
||||
implementation(project(":core:base"))
|
||||
implementation(project(":core:ktx"))
|
||||
implementation(project(":core:crashreporter"))
|
||||
implementation(project(":data:favorites"))
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,188 @@
|
||||
package de.mm20.launcher2.data.customattrs
|
||||
|
||||
import android.util.Log
|
||||
import de.mm20.launcher2.database.entities.CustomAttributeEntity
|
||||
import de.mm20.launcher2.ktx.jsonObjectOf
|
||||
import org.json.JSONObject
|
||||
|
||||
sealed interface CustomAttribute {
|
||||
fun toDatabaseEntity(key: String): CustomAttributeEntity
|
||||
|
||||
companion object {
|
||||
internal fun fromDatabaseEntity(entity: CustomAttributeEntity?): CustomAttribute? {
|
||||
if (entity == null) return null
|
||||
return when (entity.type) {
|
||||
CustomAttributeType.Label.value -> CustomLabel(
|
||||
label = entity.value,
|
||||
key = entity.key
|
||||
)
|
||||
CustomAttributeType.Tag.value -> CustomTag(
|
||||
tagName = entity.value
|
||||
)
|
||||
CustomAttributeType.Icon.value -> CustomIcon.fromDatabaseEntity(entity)
|
||||
else -> {
|
||||
Log.e("MM20", "Invalid custom attribute type: ${entity.type}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class CustomLabel(
|
||||
val key: String,
|
||||
val label: String,
|
||||
) : CustomAttribute {
|
||||
override fun toDatabaseEntity(key: String): CustomAttributeEntity {
|
||||
return CustomAttributeEntity(
|
||||
key = key,
|
||||
type = CustomAttributeType.Label.value,
|
||||
value = label,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class CustomTag(
|
||||
val tagName: String
|
||||
): CustomAttribute {
|
||||
override fun toDatabaseEntity(key: String): CustomAttributeEntity {
|
||||
return CustomAttributeEntity(
|
||||
key = key,
|
||||
type = CustomAttributeType.Tag.value,
|
||||
value = tagName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
sealed class CustomIcon : CustomAttribute {
|
||||
|
||||
override fun toDatabaseEntity(key: String): CustomAttributeEntity {
|
||||
return CustomAttributeEntity(
|
||||
key = key,
|
||||
type = CustomAttributeType.Icon.value,
|
||||
value = this.toDatabaseValue()
|
||||
)
|
||||
}
|
||||
|
||||
internal abstract fun toDatabaseValue(): String
|
||||
|
||||
companion object {
|
||||
internal fun fromDatabaseEntity(entity: CustomAttributeEntity): CustomIcon? {
|
||||
val payload = JSONObject(entity.value)
|
||||
val type = payload.getString("type")
|
||||
return when (type) {
|
||||
"custom_icon_pack_icon" -> {
|
||||
CustomIconPackIcon(
|
||||
iconComponentName = payload.getString("icon"),
|
||||
iconPackPackage = payload.getString("icon_pack")
|
||||
)
|
||||
}
|
||||
"custom_themed_icon" -> {
|
||||
CustomThemedIcon(
|
||||
iconPackageName = payload.getString("icon"),
|
||||
)
|
||||
}
|
||||
"default_icon" -> {
|
||||
UnmodifiedSystemDefaultIcon
|
||||
}
|
||||
"adaptified_legacy_icon" -> {
|
||||
AdaptifiedLegacyIcon(
|
||||
fgScale = payload.getDouble("fg_scale").toFloat(),
|
||||
bgColor = payload.getInt("bg_color")
|
||||
)
|
||||
}
|
||||
"force_themed_icon" -> ForceThemedIcon
|
||||
"default_placeholder_icon" -> DefaultPlaceholderIcon
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CustomIconPackIcon(
|
||||
val iconPackPackage: String,
|
||||
val iconComponentName: String,
|
||||
) : CustomIcon() {
|
||||
override fun toDatabaseValue(): String {
|
||||
return jsonObjectOf(
|
||||
"type" to "custom_icon_pack_icon",
|
||||
"icon" to iconComponentName,
|
||||
"icon_pack" to iconPackPackage,
|
||||
).toString()
|
||||
}
|
||||
}
|
||||
|
||||
data class AdaptifiedLegacyIcon(
|
||||
val fgScale: Float,
|
||||
/**
|
||||
* The background color in ARGB format or [UnspecifiedColor] or [ThemeColor]
|
||||
*/
|
||||
val bgColor: Int = UnspecifiedColor,
|
||||
): CustomIcon() {
|
||||
override fun toDatabaseValue(): String {
|
||||
return jsonObjectOf(
|
||||
"type" to "adaptified_legacy_icon",
|
||||
"fg_scale" to fgScale,
|
||||
"bg_color" to bgColor,
|
||||
).toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Extract color from foreground icon
|
||||
*/
|
||||
const val UnspecifiedColor = 1
|
||||
|
||||
/**
|
||||
* Use color from theme
|
||||
*/
|
||||
const val ThemeColor = 0
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class CustomThemedIcon(
|
||||
val iconPackageName: String,
|
||||
) : CustomIcon() {
|
||||
override fun toDatabaseValue(): String {
|
||||
return jsonObjectOf(
|
||||
"type" to "custom_themed_icon",
|
||||
"icon" to iconPackageName,
|
||||
).toString()
|
||||
}
|
||||
}
|
||||
|
||||
object ForceThemedIcon : CustomIcon() {
|
||||
override fun toDatabaseValue(): String {
|
||||
return jsonObjectOf(
|
||||
"type" to "force_themed_icon"
|
||||
).toString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use default icon, ignore any icon pack, themed icon or force adaptive settings.
|
||||
*/
|
||||
object UnmodifiedSystemDefaultIcon: CustomIcon() {
|
||||
override fun toDatabaseValue(): String {
|
||||
return jsonObjectOf(
|
||||
"type" to "default_icon"
|
||||
).toString()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the default placeholder icon
|
||||
*/
|
||||
object DefaultPlaceholderIcon: CustomIcon() {
|
||||
override fun toDatabaseValue(): String {
|
||||
return jsonObjectOf(
|
||||
"type" to "default_placeholder_icon"
|
||||
).toString()
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package de.mm20.launcher2.data.customattrs
|
||||
|
||||
enum class CustomAttributeType(val value: String) {
|
||||
Icon("icon"),
|
||||
Label("label"),
|
||||
Tag("tag");
|
||||
|
||||
companion object {
|
||||
internal fun fromValue(value: String): CustomAttributeType {
|
||||
return values().first { it.value == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package de.mm20.launcher2.data.customattrs
|
||||
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.database.AppDatabase
|
||||
import de.mm20.launcher2.database.entities.CustomAttributeEntity
|
||||
import de.mm20.launcher2.favorites.FavoritesRepository
|
||||
import de.mm20.launcher2.ktx.jsonObjectOf
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import java.io.File
|
||||
|
||||
interface CustomAttributesRepository {
|
||||
|
||||
fun search(query: String): Flow<ImmutableList<SavableSearchable>>
|
||||
|
||||
fun getCustomIcon(searchable: SavableSearchable): Flow<CustomIcon?>
|
||||
fun setCustomIcon(searchable: SavableSearchable, icon: CustomIcon?)
|
||||
|
||||
fun getCustomLabels(items: List<SavableSearchable>): Flow<List<CustomLabel>>
|
||||
fun setCustomLabel(searchable: SavableSearchable, label: String)
|
||||
fun clearCustomLabel(searchable: SavableSearchable)
|
||||
|
||||
fun setTags(searchable: SavableSearchable, tags: List<String>)
|
||||
fun getTags(searchable: SavableSearchable): Flow<List<String>>
|
||||
|
||||
suspend fun export(toDir: File)
|
||||
suspend fun import(fromDir: File)
|
||||
|
||||
suspend fun getAllTags(startsWith: String? = null): List<String>
|
||||
fun getItemsForTag(tag: String): Flow<List<SavableSearchable>>
|
||||
fun addTag(item: SavableSearchable, tag: String)
|
||||
|
||||
fun renameTag(oldName: String, newName: String)
|
||||
suspend fun cleanupDatabase(): Int
|
||||
}
|
||||
|
||||
internal class CustomAttributesRepositoryImpl(
|
||||
private val appDatabase: AppDatabase,
|
||||
private val favoritesRepository: FavoritesRepository
|
||||
) : CustomAttributesRepository {
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
|
||||
override fun getCustomIcon(searchable: SavableSearchable): Flow<CustomIcon?> {
|
||||
val dao = appDatabase.customAttrsDao()
|
||||
return dao.getCustomAttribute(searchable.key, CustomAttributeType.Icon.value)
|
||||
.map {
|
||||
CustomAttribute.fromDatabaseEntity(it) as? CustomIcon
|
||||
}
|
||||
}
|
||||
|
||||
override fun setCustomIcon(searchable: SavableSearchable, icon: CustomIcon?) {
|
||||
val dao = appDatabase.customAttrsDao()
|
||||
scope.launch {
|
||||
dao.clearCustomAttribute(searchable.key, CustomAttributeType.Icon.value)
|
||||
if (icon != null) {
|
||||
dao.setCustomAttribute(icon.toDatabaseEntity(searchable.key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCustomLabels(items: List<SavableSearchable>): Flow<List<CustomLabel>> {
|
||||
val dao = appDatabase.customAttrsDao()
|
||||
return dao.getCustomAttributes(items.map { it.key }, CustomAttributeType.Label.value)
|
||||
.map { list ->
|
||||
list.mapNotNull { CustomAttribute.fromDatabaseEntity(it) as? CustomLabel }
|
||||
}
|
||||
}
|
||||
|
||||
override fun setCustomLabel(searchable: SavableSearchable, label: String) {
|
||||
val dao = appDatabase.customAttrsDao()
|
||||
scope.launch {
|
||||
favoritesRepository.save(searchable)
|
||||
appDatabase.runInTransaction {
|
||||
dao.clearCustomAttribute(searchable.key, CustomAttributeType.Label.value)
|
||||
dao.setCustomAttribute(
|
||||
CustomLabel(
|
||||
key = searchable.key,
|
||||
label = label,
|
||||
).toDatabaseEntity(searchable.key)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearCustomLabel(searchable: SavableSearchable) {
|
||||
val dao = appDatabase.customAttrsDao()
|
||||
scope.launch {
|
||||
dao.clearCustomAttribute(searchable.key, CustomAttributeType.Label.value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setTags(searchable: SavableSearchable, tags: List<String>) {
|
||||
val dao = appDatabase.customAttrsDao()
|
||||
scope.launch {
|
||||
favoritesRepository.save(searchable)
|
||||
dao.setTags(searchable.key, tags.map {
|
||||
CustomTag(it).toDatabaseEntity(searchable.key)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun getTags(searchable: SavableSearchable): Flow<List<String>> {
|
||||
val dao = appDatabase.customAttrsDao()
|
||||
return dao.getCustomAttributes(listOf(searchable.key), CustomAttributeType.Tag.value).map {
|
||||
it.map { it.value }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAllTags(startsWith: String?): List<String> {
|
||||
val dao = appDatabase.customAttrsDao()
|
||||
return if (startsWith != null) {
|
||||
dao.getAllTagsLike("$startsWith%")
|
||||
} else {
|
||||
dao.getAllTags()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemsForTag(tag: String): Flow<List<SavableSearchable>> {
|
||||
val dao = appDatabase.customAttrsDao()
|
||||
return dao.getItemsWithTag(tag).map {
|
||||
favoritesRepository.getFromKeys(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun addTag(item: SavableSearchable, tag: String) {
|
||||
val dao = appDatabase.customAttrsDao()
|
||||
scope.launch {
|
||||
dao.addTag(item.key, tag)
|
||||
}
|
||||
}
|
||||
|
||||
override fun renameTag(oldName: String, newName: String) {
|
||||
val dao = appDatabase.customAttrsDao()
|
||||
scope.launch {
|
||||
dao.renameTag(oldName, newName)
|
||||
}
|
||||
}
|
||||
|
||||
override fun search(query: String): Flow<ImmutableList<SavableSearchable>> {
|
||||
if (query.isBlank()) {
|
||||
return flow {
|
||||
emit(persistentListOf())
|
||||
}
|
||||
}
|
||||
val dao = appDatabase.customAttrsDao()
|
||||
return dao.search("%$query%").map {
|
||||
favoritesRepository.getFromKeys(it).toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun export(toDir: File) = withContext(Dispatchers.IO) {
|
||||
val dao = appDatabase.backupDao()
|
||||
var page = 0
|
||||
do {
|
||||
val customAttrs = dao.exportCustomAttributes(limit = 100, offset = page * 100)
|
||||
val jsonArray = JSONArray()
|
||||
for (customAttr in customAttrs) {
|
||||
jsonArray.put(
|
||||
jsonObjectOf(
|
||||
"key" to customAttr.key,
|
||||
"value" to customAttr.value,
|
||||
"type" to customAttr.type,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val file = File(toDir, "customizations.${page.toString().padStart(4, '0')}")
|
||||
file.bufferedWriter().use {
|
||||
it.write(jsonArray.toString())
|
||||
}
|
||||
page++
|
||||
} while (customAttrs.size == 100)
|
||||
}
|
||||
|
||||
override suspend fun import(fromDir: File) = withContext(Dispatchers.IO) {
|
||||
val dao = appDatabase.backupDao()
|
||||
dao.wipeCustomAttributes()
|
||||
|
||||
val files =
|
||||
fromDir.listFiles { _, name -> name.startsWith("customizations.") }
|
||||
?: return@withContext
|
||||
|
||||
for (file in files) {
|
||||
val customAttrs = mutableListOf<CustomAttributeEntity>()
|
||||
try {
|
||||
val jsonArray = JSONArray(file.inputStream().reader().readText())
|
||||
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val json = jsonArray.getJSONObject(i)
|
||||
|
||||
val entity = CustomAttributeEntity(
|
||||
id = null,
|
||||
type = json.getString("type"),
|
||||
value = json.optString("value"),
|
||||
key = json.optString("key"),
|
||||
)
|
||||
customAttrs.add(entity)
|
||||
}
|
||||
|
||||
dao.importCustomAttributes(customAttrs)
|
||||
|
||||
} catch (e: JSONException) {
|
||||
CrashReporter.logException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cleanupDatabase(): Int {
|
||||
val dao = appDatabase.backupDao()
|
||||
var removed = 0
|
||||
val job = scope.launch {
|
||||
removed = dao.cleanUp()
|
||||
}
|
||||
job.join()
|
||||
return removed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.mm20.launcher2.data.customattrs
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val customAttrsModule = module {
|
||||
single<CustomAttributesRepository> { CustomAttributesRepositoryImpl(get(), get()) }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.mm20.launcher2.data.customattrs.utils
|
||||
|
||||
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
fun <T: SavableSearchable>Flow<List<T>>.withCustomLabels(
|
||||
customAttributesRepository: CustomAttributesRepository,
|
||||
): Flow<List<T>> = channelFlow {
|
||||
this@withCustomLabels.collectLatest { items ->
|
||||
val customLabels = customAttributesRepository.getCustomLabels(items)
|
||||
customLabels.collectLatest { labels ->
|
||||
send(items.map { item ->
|
||||
val customLabel = labels.find { it.key == item.key }
|
||||
if (customLabel != null) {
|
||||
item.overrideLabel(customLabel.label) as T
|
||||
} else {
|
||||
item
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,59 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = sdk.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = sdk.versions.minSdk.get().toInt()
|
||||
targetSdk = sdk.versions.targetSdk.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
namespace = "de.mm20.launcher2.favorites"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.bundles.kotlin)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.bundles.androidx.lifecycle)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
|
||||
implementation(project(":core:base"))
|
||||
implementation(project(":data:calendar"))
|
||||
implementation(project(":core:database"))
|
||||
implementation(project(":core:preferences"))
|
||||
implementation(project(":data:applications"))
|
||||
implementation(project(":data:appshortcuts"))
|
||||
implementation(project(":data:contacts"))
|
||||
implementation(project(":core:ktx"))
|
||||
implementation(project(":data:files"))
|
||||
implementation(project(":data:websites"))
|
||||
implementation(project(":data:wikipedia"))
|
||||
implementation(project(":services:badges"))
|
||||
implementation(project(":core:crashreporter"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.kts.kts.kts.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1 @@
|
||||
<manifest />
|
||||
@@ -0,0 +1,400 @@
|
||||
package de.mm20.launcher2.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.database.AppDatabase
|
||||
import de.mm20.launcher2.database.entities.SavedSearchableEntity
|
||||
import de.mm20.launcher2.ktx.jsonObjectOf
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.koin.core.component.KoinComponent
|
||||
import java.io.File
|
||||
|
||||
interface FavoritesRepository {
|
||||
/**
|
||||
* Get favorites
|
||||
* @param includeTypes Include only items of these types. Cannot be used together with excludeTypes.
|
||||
* @param excludeTypes Exclude only items of these types. Cannot be used together with includeTypes.
|
||||
* @param manuallySorted Include items that have been sorted manually
|
||||
* @param automaticallySorted Include items that are pinned but not sorted
|
||||
* @param frequentlyUsed Include items that are not pinned but most frequently used
|
||||
* @param limit Maximum number of items returned.
|
||||
*/
|
||||
fun getFavorites(
|
||||
includeTypes: List<String>? = null,
|
||||
excludeTypes: List<String>? = null,
|
||||
manuallySorted: Boolean = false,
|
||||
automaticallySorted: Boolean = false,
|
||||
frequentlyUsed: Boolean = false,
|
||||
limit: Int = 100
|
||||
): Flow<List<SavableSearchable>>
|
||||
|
||||
|
||||
fun getHiddenCalendarEventKeys(): Flow<List<String>>
|
||||
fun isPinned(searchable: SavableSearchable): Flow<Boolean>
|
||||
fun pinItem(searchable: SavableSearchable)
|
||||
fun unpinItem(searchable: SavableSearchable)
|
||||
fun isHidden(searchable: SavableSearchable): Flow<Boolean>
|
||||
fun hideItem(searchable: SavableSearchable)
|
||||
fun unhideItem(searchable: SavableSearchable)
|
||||
fun incrementLaunchCounter(searchable: SavableSearchable)
|
||||
fun updateFavorites(
|
||||
manuallySorted: List<SavableSearchable>,
|
||||
automaticallySorted: List<SavableSearchable>,
|
||||
)
|
||||
|
||||
fun getHiddenItems(): Flow<List<SavableSearchable>>
|
||||
fun getHiddenItemKeys(): Flow<List<String>>
|
||||
|
||||
/**
|
||||
* Remove this item from the Searchable database
|
||||
*/
|
||||
fun remove(searchable: SavableSearchable)
|
||||
|
||||
/**
|
||||
* Remove this item from favorites and reset launch counter
|
||||
*/
|
||||
fun removeFromFavorites(searchable: SavableSearchable)
|
||||
|
||||
/**
|
||||
* Ensure that this searchable exists in the Favorites table.
|
||||
* If it doesn't exist, insert it with 0 launch count, not pinned and not hidden
|
||||
*/
|
||||
fun save(searchable: SavableSearchable)
|
||||
|
||||
/**
|
||||
* Get items with the given keys from the favorites database.
|
||||
* Items that don't exist in the database will not be returned.
|
||||
*/
|
||||
suspend fun getFromKeys(keys: List<String>): List<SavableSearchable>
|
||||
|
||||
suspend fun export(toDir: File)
|
||||
suspend fun import(fromDir: File)
|
||||
|
||||
/**
|
||||
* Remove database entries that are invalid. This includes
|
||||
* - entries that cannot be deserialized anymore
|
||||
* - entries that are inconsistent (the key column is not equal to the key of the searchable)
|
||||
*/
|
||||
suspend fun cleanupDatabase(): Int
|
||||
}
|
||||
|
||||
internal class FavoritesRepositoryImpl(
|
||||
private val context: Context,
|
||||
private val database: AppDatabase,
|
||||
) : FavoritesRepository, KoinComponent {
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
|
||||
override fun getFavorites(
|
||||
includeTypes: List<String>?,
|
||||
excludeTypes: List<String>?,
|
||||
manuallySorted: Boolean,
|
||||
automaticallySorted: Boolean,
|
||||
frequentlyUsed: Boolean,
|
||||
limit: Int
|
||||
): Flow<List<SavableSearchable>> {
|
||||
val dao = database.searchDao()
|
||||
val entities = when {
|
||||
includeTypes == null && excludeTypes == null -> dao.getFavorites(
|
||||
manuallySorted = manuallySorted,
|
||||
automaticallySorted = automaticallySorted,
|
||||
frequentlyUsed = frequentlyUsed,
|
||||
limit = limit
|
||||
)
|
||||
includeTypes != null && excludeTypes == null -> {
|
||||
dao.getFavoritesWithTypes(
|
||||
includeTypes = includeTypes,
|
||||
manuallySorted = manuallySorted,
|
||||
automaticallySorted = automaticallySorted,
|
||||
frequentlyUsed = frequentlyUsed,
|
||||
limit = limit
|
||||
)
|
||||
}
|
||||
excludeTypes != null && includeTypes == null -> {
|
||||
dao.getFavoritesWithoutTypes(
|
||||
excludeTypes = excludeTypes,
|
||||
manuallySorted = manuallySorted,
|
||||
automaticallySorted = automaticallySorted,
|
||||
frequentlyUsed = frequentlyUsed,
|
||||
limit = limit
|
||||
)
|
||||
}
|
||||
else -> throw IllegalArgumentException("You can either use includeTypes or excludeTypes, not both")
|
||||
}
|
||||
return entities.map {
|
||||
it.mapNotNull { fromDatabaseEntity(it).searchable }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getHiddenCalendarEventKeys(): Flow<List<String>> {
|
||||
return database.searchDao().getHiddenCalendarEventKeys()
|
||||
}
|
||||
|
||||
override fun isPinned(searchable: SavableSearchable): Flow<Boolean> {
|
||||
return AppDatabase.getInstance(context).searchDao().isPinned(searchable.key)
|
||||
}
|
||||
|
||||
override fun pinItem(searchable: SavableSearchable) {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val dao = AppDatabase.getInstance(context).searchDao()
|
||||
val databaseItem = dao.getFavorite(searchable.key)
|
||||
val savedSearchable = SavedSearchable(
|
||||
key = searchable.key,
|
||||
searchable = searchable,
|
||||
launchCount = databaseItem?.launchCount ?: 0,
|
||||
pinPosition = 1,
|
||||
hidden = false
|
||||
)
|
||||
savedSearchable.toDatabaseEntity()?.let { dao.insertReplaceExisting(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unpinItem(searchable: SavableSearchable) {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
AppDatabase.getInstance(context).searchDao().unpinFavorite(searchable.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isHidden(searchable: SavableSearchable): Flow<Boolean> {
|
||||
return AppDatabase.getInstance(context).searchDao().isHidden(searchable.key)
|
||||
}
|
||||
|
||||
override fun hideItem(searchable: SavableSearchable) {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val dao = AppDatabase.getInstance(context).searchDao()
|
||||
val databaseItem = dao.getFavorite(searchable.key)
|
||||
val savedSearchable = SavedSearchable(
|
||||
key = searchable.key,
|
||||
searchable = searchable,
|
||||
launchCount = databaseItem?.launchCount ?: 0,
|
||||
pinPosition = 0,
|
||||
hidden = true
|
||||
)
|
||||
savedSearchable.toDatabaseEntity()?.let { dao.insertReplaceExisting(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unhideItem(searchable: SavableSearchable) {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
AppDatabase.getInstance(context).searchDao().unhideItem(searchable.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun incrementLaunchCounter(searchable: SavableSearchable) {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val item = SavedSearchable(searchable.key, searchable, 0, 0, false)
|
||||
item.toDatabaseEntity()?.let {
|
||||
AppDatabase.getInstance(context).searchDao()
|
||||
.incrementLaunchCount(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getHiddenItems(): Flow<List<SavableSearchable>> {
|
||||
return database.searchDao().getHiddenItems().map {
|
||||
it.mapNotNull { fromDatabaseEntity(it).searchable }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getHiddenItemKeys(): Flow<List<String>> {
|
||||
return database.searchDao().getHiddenItemKeys()
|
||||
}
|
||||
|
||||
override fun remove(searchable: SavableSearchable) {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
database.searchDao().deleteByKey(searchable.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeFromFavorites(searchable: SavableSearchable) {
|
||||
scope.launch {
|
||||
database.searchDao().resetPinStatusAndLaunchCounter(searchable.key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun save(searchable: SavableSearchable) {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val entity = SavedSearchable(
|
||||
key = searchable.key,
|
||||
searchable = searchable,
|
||||
launchCount = 0,
|
||||
pinPosition = 0,
|
||||
hidden = false,
|
||||
).toDatabaseEntity() ?: return@withContext
|
||||
database.searchDao().insertSkipExisting(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateFavorites(
|
||||
manuallySorted: List<SavableSearchable>,
|
||||
automaticallySorted: List<SavableSearchable>
|
||||
) {
|
||||
val dao = database.searchDao()
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val keys = manuallySorted.map { it.key } + automaticallySorted.map { it.key }
|
||||
val entities = dao.getFromKeys(keys)
|
||||
val updatedManuallySorted = manuallySorted.mapIndexedNotNull { index, searchable ->
|
||||
val entity = entities.find { searchable.key == it.key } ?: SavedSearchable(
|
||||
key = searchable.key,
|
||||
searchable = searchable,
|
||||
launchCount = 0,
|
||||
pinPosition = 0,
|
||||
hidden = false,
|
||||
).toDatabaseEntity() ?: return@mapIndexedNotNull null
|
||||
entity.pinPosition = manuallySorted.size - index + 1
|
||||
entity
|
||||
}
|
||||
val updatedAutomaticallySorted =
|
||||
automaticallySorted.mapIndexedNotNull { index, searchable ->
|
||||
val entity = entities.find { searchable.key == it.key } ?: SavedSearchable(
|
||||
key = searchable.key,
|
||||
searchable = searchable,
|
||||
launchCount = 0,
|
||||
pinPosition = 0,
|
||||
hidden = false,
|
||||
).toDatabaseEntity() ?: return@mapIndexedNotNull null
|
||||
entity.pinPosition = 1
|
||||
entity
|
||||
}
|
||||
database.runInTransaction {
|
||||
dao.unpinAll()
|
||||
dao.insertAllReplaceExisting(updatedManuallySorted)
|
||||
dao.insertAllReplaceExisting(updatedAutomaticallySorted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun fromDatabaseEntity(entity: SavedSearchableEntity): SavedSearchable {
|
||||
val deserializer: SearchableDeserializer =
|
||||
getDeserializer(context, entity.type)
|
||||
val searchable = deserializer.deserialize(entity.serializedSearchable)
|
||||
if (searchable == null) removeInvalidItem(entity.key)
|
||||
return SavedSearchable(
|
||||
key = entity.key,
|
||||
searchable = searchable,
|
||||
launchCount = entity.launchCount,
|
||||
pinPosition = entity.pinPosition,
|
||||
hidden = entity.hidden
|
||||
)
|
||||
}
|
||||
|
||||
private fun removeInvalidItem(key: String) {
|
||||
scope.launch {
|
||||
database.searchDao().deleteByKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getFromKeys(keys: List<String>): List<SavableSearchable> {
|
||||
val dao = database.searchDao()
|
||||
return dao.getFromKeys(keys)
|
||||
.mapNotNull { fromDatabaseEntity(it).searchable }
|
||||
}
|
||||
|
||||
override suspend fun export(toDir: File) = withContext(Dispatchers.IO) {
|
||||
val dao = database.backupDao()
|
||||
var page = 0
|
||||
do {
|
||||
val favorites = dao.exportFavorites(limit = 100, offset = page * 100)
|
||||
val jsonArray = JSONArray()
|
||||
for (fav in favorites) {
|
||||
jsonArray.put(
|
||||
jsonObjectOf(
|
||||
"key" to fav.key,
|
||||
"type" to fav.type,
|
||||
"hidden" to fav.hidden,
|
||||
"launchCount" to fav.launchCount,
|
||||
"pinPosition" to fav.pinPosition,
|
||||
"searchable" to fav.serializedSearchable
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val file = File(toDir, "favorites.${page.toString().padStart(4, '0')}")
|
||||
file.bufferedWriter().use {
|
||||
it.write(jsonArray.toString())
|
||||
}
|
||||
page++
|
||||
} while (favorites.size == 100)
|
||||
}
|
||||
|
||||
override suspend fun import(fromDir: File) = withContext(Dispatchers.IO) {
|
||||
val dao = database.backupDao()
|
||||
dao.wipeFavorites()
|
||||
|
||||
val files =
|
||||
fromDir.listFiles { _, name -> name.startsWith("favorites.") } ?: return@withContext
|
||||
|
||||
for (file in files) {
|
||||
val favorites = mutableListOf<SavedSearchableEntity>()
|
||||
try {
|
||||
val jsonArray = JSONArray(file.inputStream().reader().readText())
|
||||
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val json = jsonArray.getJSONObject(i)
|
||||
val entity = SavedSearchableEntity(
|
||||
key = json.getString("key"),
|
||||
type = json.optString("type").takeIf { it.isNotEmpty() } ?: continue,
|
||||
serializedSearchable = json.getString("searchable"),
|
||||
launchCount = json.getInt("launchCount"),
|
||||
hidden = json.getBoolean("hidden"),
|
||||
pinPosition = json.getInt("pinPosition")
|
||||
)
|
||||
favorites.add(entity)
|
||||
}
|
||||
|
||||
dao.importFavorites(favorites)
|
||||
|
||||
} catch (e: JSONException) {
|
||||
CrashReporter.logException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cleanupDatabase(): Int {
|
||||
var removed = 0
|
||||
val job = scope.launch {
|
||||
val dao = database.backupDao()
|
||||
var page = 0
|
||||
do {
|
||||
val favorites = dao.exportFavorites(limit = 100, offset = page * 100)
|
||||
for (fav in favorites) {
|
||||
val item = fromDatabaseEntity(fav)
|
||||
if (item.searchable == null || item.searchable.key != item.key) {
|
||||
removeInvalidItem(item.key)
|
||||
removed++
|
||||
Log.i(
|
||||
"MM20",
|
||||
"SearchableDatabase cleanup: removed invalid item ${item.key}"
|
||||
)
|
||||
}
|
||||
}
|
||||
page++
|
||||
} while (favorites.size == 100)
|
||||
}
|
||||
job.join()
|
||||
return removed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.mm20.launcher2.favorites
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val favoritesModule = module {
|
||||
single<FavoritesRepository> { FavoritesRepositoryImpl(androidContext(), get()) }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.mm20.launcher2.favorites
|
||||
|
||||
import de.mm20.launcher2.database.entities.SavedSearchableEntity
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
|
||||
data class SavedSearchable(
|
||||
val key: String,
|
||||
/**
|
||||
* null if searchable could not be deserialized (i.e. the app has been uninstalled)
|
||||
*/
|
||||
val searchable: SavableSearchable?,
|
||||
var launchCount: Int,
|
||||
var pinPosition: Int,
|
||||
var hidden: Boolean
|
||||
) {
|
||||
fun toDatabaseEntity(): SavedSearchableEntity? {
|
||||
val serializer = getSerializer(searchable)
|
||||
|
||||
val data = searchable?.let { serializer.serialize(it) } ?: return null
|
||||
|
||||
return SavedSearchableEntity(
|
||||
key = key,
|
||||
type = searchable.domain,
|
||||
serializedSearchable = data,
|
||||
hidden = hidden,
|
||||
pinPosition = pinPosition,
|
||||
launchCount = launchCount
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.mm20.launcher2.favorites
|
||||
|
||||
import android.content.Context
|
||||
import de.mm20.launcher2.appshortcuts.LauncherShortcutDeserializer
|
||||
import de.mm20.launcher2.appshortcuts.LauncherShortcutSerializer
|
||||
import de.mm20.launcher2.appshortcuts.LegacyShortcutDeserializer
|
||||
import de.mm20.launcher2.appshortcuts.LegacyShortcutSerializer
|
||||
import de.mm20.launcher2.calendar.CalendarEventDeserializer
|
||||
import de.mm20.launcher2.calendar.CalendarEventSerializer
|
||||
import de.mm20.launcher2.contacts.ContactDeserializer
|
||||
import de.mm20.launcher2.contacts.ContactSerializer
|
||||
import de.mm20.launcher2.files.*
|
||||
import de.mm20.launcher2.search.NullDeserializer
|
||||
import de.mm20.launcher2.search.NullSerializer
|
||||
import de.mm20.launcher2.search.Searchable
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import de.mm20.launcher2.search.data.*
|
||||
import de.mm20.launcher2.websites.WebsiteDeserializer
|
||||
import de.mm20.launcher2.websites.WebsiteSerializer
|
||||
import de.mm20.launcher2.wikipedia.WikipediaDeserializer
|
||||
import de.mm20.launcher2.wikipedia.WikipediaSerializer
|
||||
|
||||
|
||||
internal fun getSerializer(searchable: Searchable?): SearchableSerializer {
|
||||
if (searchable is LauncherApp) {
|
||||
return LauncherAppSerializer()
|
||||
}
|
||||
if (searchable is LauncherShortcut) {
|
||||
return LauncherShortcutSerializer()
|
||||
}
|
||||
if (searchable is LegacyShortcut) {
|
||||
return LegacyShortcutSerializer()
|
||||
}
|
||||
if (searchable is CalendarEvent) {
|
||||
return CalendarEventSerializer()
|
||||
}
|
||||
if (searchable is Contact) {
|
||||
return ContactSerializer()
|
||||
}
|
||||
if (searchable is Wikipedia) {
|
||||
return WikipediaSerializer()
|
||||
}
|
||||
if (searchable is GDriveFile) {
|
||||
return GDriveFileSerializer()
|
||||
}
|
||||
if (searchable is OneDriveFile) {
|
||||
return OneDriveFileSerializer()
|
||||
}
|
||||
if (searchable is OwncloudFile) {
|
||||
return OwncloudFileSerializer()
|
||||
}
|
||||
if (searchable is NextcloudFile) {
|
||||
return NextcloudFileSerializer()
|
||||
}
|
||||
if (searchable is LocalFile) {
|
||||
return LocalFileSerializer()
|
||||
}
|
||||
if (searchable is Website) {
|
||||
return WebsiteSerializer()
|
||||
}
|
||||
if (searchable is Tag) {
|
||||
return TagSerializer()
|
||||
}
|
||||
return NullSerializer()
|
||||
}
|
||||
|
||||
internal fun getDeserializer(context: Context, type: String): SearchableDeserializer {
|
||||
if (type == LauncherApp.Domain) {
|
||||
return LauncherAppDeserializer(context)
|
||||
}
|
||||
if (type == LauncherShortcut.Domain) {
|
||||
return LauncherShortcutDeserializer(context)
|
||||
}
|
||||
if (type == LegacyShortcut.Domain) {
|
||||
return LegacyShortcutDeserializer(context)
|
||||
}
|
||||
if (type == CalendarEvent.Domain) {
|
||||
return CalendarEventDeserializer(context)
|
||||
}
|
||||
if (type == Contact.Domain) {
|
||||
return ContactDeserializer(context)
|
||||
}
|
||||
if (type == Wikipedia.Domain) {
|
||||
return WikipediaDeserializer(context)
|
||||
}
|
||||
if (type == GDriveFile.Domain) {
|
||||
return GDriveFileDeserializer()
|
||||
}
|
||||
if (type == OneDriveFile.Domain) {
|
||||
return OneDriveFileDeserializer()
|
||||
}
|
||||
if (type == NextcloudFile.Domain) {
|
||||
return NextcloudFileDeserializer()
|
||||
}
|
||||
if (type == OwncloudFile.Domain) {
|
||||
return OwncloudFileDeserializer()
|
||||
}
|
||||
if (type == LocalFile.Domain) {
|
||||
return LocalFileDeserializer(context)
|
||||
}
|
||||
if (type == Website.Domain) {
|
||||
return WebsiteDeserializer()
|
||||
}
|
||||
if (type == Tag.Domain) {
|
||||
return TagDeserializer()
|
||||
}
|
||||
return NullDeserializer()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.mm20.launcher2.favorites
|
||||
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import de.mm20.launcher2.search.data.Tag
|
||||
import org.json.JSONObject
|
||||
|
||||
class TagSerializer: SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as Tag
|
||||
val json = JSONObject()
|
||||
json.put("tag", searchable.tag)
|
||||
return json.toString()
|
||||
}
|
||||
|
||||
override val typePrefix: String
|
||||
get() = "tag"
|
||||
}
|
||||
|
||||
class TagDeserializer: SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable {
|
||||
val json = JSONObject(serialized)
|
||||
|
||||
return Tag(json.getString("tag"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import de.mm20.launcher2.icons.ColorLayer
|
||||
import de.mm20.launcher2.icons.StaticLauncherIcon
|
||||
import de.mm20.launcher2.icons.TextLayer
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
|
||||
data class Tag(
|
||||
val tag: String,
|
||||
override val labelOverride: String? = null
|
||||
): SavableSearchable {
|
||||
|
||||
override val domain: String = Domain
|
||||
|
||||
override val key: String = "$domain://$tag"
|
||||
override val label: String = tag
|
||||
|
||||
override val preferDetailsOverLaunch: Boolean = true
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
return false
|
||||
}
|
||||
override fun overrideLabel(label: String): SavableSearchable {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
|
||||
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = TextLayer("#"),
|
||||
backgroundLayer = ColorLayer()
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "tag"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,55 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = sdk.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = sdk.versions.minSdk.get().toInt()
|
||||
targetSdk = sdk.versions.targetSdk.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
namespace = "de.mm20.launcher2.files"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.bundles.kotlin)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
|
||||
implementation(libs.bundles.androidx.lifecycle)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
|
||||
implementation(project(":core:base"))
|
||||
implementation(project(":core:ktx"))
|
||||
implementation(project(":libs:ms-services"))
|
||||
implementation(project(":libs:g-services"))
|
||||
implementation(project(":libs:nextcloud"))
|
||||
implementation(project(":libs:owncloud"))
|
||||
implementation(project(":core:i18n"))
|
||||
implementation(project(":core:permissions"))
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.kts.kts.kts.kts.kts.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,4 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
/
|
||||
</manifest>
|
||||
@@ -0,0 +1,295 @@
|
||||
package de.mm20.launcher2.files
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.database.getStringOrNull
|
||||
import de.mm20.launcher2.ktx.jsonObjectOf
|
||||
import de.mm20.launcher2.permissions.PermissionGroup
|
||||
import de.mm20.launcher2.permissions.PermissionsManager
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import de.mm20.launcher2.search.data.*
|
||||
import org.json.JSONObject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
class LocalFileSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as LocalFile
|
||||
return jsonObjectOf(
|
||||
"id" to searchable.id
|
||||
).toString()
|
||||
}
|
||||
|
||||
override val typePrefix: String
|
||||
get() = "file"
|
||||
}
|
||||
|
||||
class LocalFileDeserializer(
|
||||
val context: Context
|
||||
) : SearchableDeserializer, KoinComponent {
|
||||
override fun deserialize(serialized: String): SavableSearchable? {
|
||||
val permissionsManager: PermissionsManager = get()
|
||||
if (!permissionsManager.checkPermissionOnce(
|
||||
PermissionGroup.ExternalStorage
|
||||
)
|
||||
) return null
|
||||
val json = JSONObject(serialized)
|
||||
val uri = MediaStore.Files.getContentUri("external")
|
||||
val proj = arrayOf(
|
||||
MediaStore.Files.FileColumns._ID,
|
||||
MediaStore.Files.FileColumns.SIZE,
|
||||
MediaStore.Files.FileColumns.DATA,
|
||||
MediaStore.Files.FileColumns.MIME_TYPE
|
||||
)
|
||||
val sel = "${MediaStore.Files.FileColumns._ID} = ?"
|
||||
val selArgs = arrayOf(json.getLong("id").toString())
|
||||
val cursor = context.contentResolver.query(uri, proj, sel, selArgs, null) ?: return null
|
||||
if (cursor.moveToNext()) {
|
||||
val path = cursor.getString(2)
|
||||
if (!java.io.File(path).exists()) return null
|
||||
val directory = java.io.File(path).isDirectory
|
||||
val id = cursor.getLong(0)
|
||||
val mimeType = cursor.getStringOrNull(3)
|
||||
?: if (directory) "resource/folder" else LocalFile.getMimetypeByFileExtension(
|
||||
path.substringAfterLast(
|
||||
'.'
|
||||
)
|
||||
)
|
||||
val size = cursor.getLong(1)
|
||||
cursor.close()
|
||||
return LocalFile(
|
||||
path = path,
|
||||
mimeType = mimeType,
|
||||
size = size,
|
||||
isDirectory = directory,
|
||||
id = id,
|
||||
metaData = LocalFile.getMetaData(context, mimeType, path)
|
||||
)
|
||||
}
|
||||
cursor.close()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class GDriveFileSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as GDriveFile
|
||||
return jsonObjectOf(
|
||||
"id" to searchable.fileId,
|
||||
"label" to searchable.label,
|
||||
"path" to searchable.path,
|
||||
"mimeType" to searchable.mimeType,
|
||||
"size" to searchable.size,
|
||||
"directory" to searchable.isDirectory,
|
||||
"color" to searchable.directoryColor,
|
||||
"uri" to searchable.viewUri
|
||||
).apply {
|
||||
for ((k, v) in searchable.metaData) {
|
||||
put(
|
||||
when (k) {
|
||||
R.string.file_meta_owner -> "owner"
|
||||
R.string.file_meta_dimensions -> "dimensions"
|
||||
else -> "other"
|
||||
}, v
|
||||
)
|
||||
}
|
||||
}.toString()
|
||||
}
|
||||
|
||||
override val typePrefix: String
|
||||
get() = "gdrive"
|
||||
}
|
||||
|
||||
class GDriveFileDeserializer : SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable {
|
||||
val json = JSONObject(serialized)
|
||||
val id = json.getString("id")
|
||||
val label = json.getString("label")
|
||||
val path = json.getString("path")
|
||||
val mimeType = json.getString("mimeType")
|
||||
val size = json.getLong("size")
|
||||
val directory = json.getBoolean("directory")
|
||||
val color = json.optString("color")
|
||||
val uri = json.getString("uri")
|
||||
val owner = json.optString("owner")
|
||||
val dimensions = json.optString("dimensions")
|
||||
val metaData = mutableListOf<Pair<Int, String>>()
|
||||
owner.takeIf { it.isNotEmpty() }?.let { metaData.add(R.string.file_meta_owner to it) }
|
||||
dimensions.takeIf { it.isNotEmpty() }
|
||||
?.let { metaData.add(R.string.file_meta_dimensions to it) }
|
||||
return GDriveFile(
|
||||
fileId = id,
|
||||
label = label,
|
||||
path = path,
|
||||
mimeType = mimeType,
|
||||
size = size,
|
||||
directoryColor = color,
|
||||
isDirectory = directory,
|
||||
viewUri = uri,
|
||||
metaData = metaData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class OneDriveFileSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as OneDriveFile
|
||||
return jsonObjectOf(
|
||||
"id" to searchable.fileId,
|
||||
"label" to searchable.label,
|
||||
"mimeType" to searchable.mimeType,
|
||||
"size" to searchable.size,
|
||||
"directory" to searchable.isDirectory,
|
||||
"webUrl" to searchable.webUrl
|
||||
).apply {
|
||||
for ((k, v) in searchable.metaData) {
|
||||
put(
|
||||
when (k) {
|
||||
R.string.file_meta_owner -> "owner"
|
||||
R.string.file_meta_dimensions -> "dimensions"
|
||||
else -> "other"
|
||||
}, v
|
||||
)
|
||||
}
|
||||
}.toString()
|
||||
}
|
||||
|
||||
override val typePrefix: String
|
||||
get() = "onedrive"
|
||||
}
|
||||
|
||||
class OneDriveFileDeserializer : SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable {
|
||||
val json = JSONObject(serialized)
|
||||
val fileId = json.getString("id")
|
||||
val label = json.getString("label")
|
||||
val mimeType = json.getString("mimeType")
|
||||
val size = json.getLong("size")
|
||||
val isDirectory = json.getBoolean("directory")
|
||||
val webUrl = json.getString("webUrl")
|
||||
val owner = json.optString("owner")
|
||||
val dimensions = json.optString("dimensions")
|
||||
val metaData = mutableListOf<Pair<Int, String>>()
|
||||
owner.takeIf { it.isNotEmpty() }?.let { metaData.add(R.string.file_meta_owner to it) }
|
||||
dimensions.takeIf { it.isNotEmpty() }
|
||||
?.let { metaData.add(R.string.file_meta_dimensions to it) }
|
||||
return OneDriveFile(
|
||||
fileId = fileId,
|
||||
label = label,
|
||||
path = "",
|
||||
mimeType = mimeType,
|
||||
size = size,
|
||||
isDirectory = isDirectory,
|
||||
metaData = metaData,
|
||||
webUrl = webUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class NextcloudFileSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as NextcloudFile
|
||||
return jsonObjectOf(
|
||||
"id" to searchable.fileId,
|
||||
"label" to searchable.label,
|
||||
"path" to searchable.path,
|
||||
"mimeType" to searchable.mimeType,
|
||||
"size" to searchable.size,
|
||||
"isDirectory" to searchable.isDirectory,
|
||||
"server" to searchable.server
|
||||
).apply {
|
||||
for ((k, v) in searchable.metaData) {
|
||||
put(
|
||||
when (k) {
|
||||
R.string.file_meta_owner -> "owner"
|
||||
else -> "other"
|
||||
}, v
|
||||
)
|
||||
}
|
||||
}.toString()
|
||||
}
|
||||
|
||||
override val typePrefix: String
|
||||
get() = "nextcloud"
|
||||
}
|
||||
|
||||
class NextcloudFileDeserializer : SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable {
|
||||
val json = JSONObject(serialized)
|
||||
val id = json.getLong("id")
|
||||
val label = json.getString("label")
|
||||
val path = json.getString("path")
|
||||
val mimeType = json.getString("mimeType")
|
||||
val size = json.getLong("size")
|
||||
val isDirectory = json.getBoolean("isDirectory")
|
||||
val server = json.getString("server")
|
||||
val owner = json.optString("owner").takeIf { it.isNotEmpty() }
|
||||
|
||||
return NextcloudFile(
|
||||
fileId = id,
|
||||
label = label,
|
||||
path = path,
|
||||
mimeType = mimeType,
|
||||
size = size,
|
||||
isDirectory = isDirectory,
|
||||
server = server,
|
||||
metaData = owner?.let { listOf(R.string.file_meta_owner to it) } ?: emptyList()
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class OwncloudFileSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as OwncloudFile
|
||||
return jsonObjectOf(
|
||||
"id" to searchable.fileId,
|
||||
"label" to searchable.label,
|
||||
"path" to searchable.path,
|
||||
"mimeType" to searchable.mimeType,
|
||||
"size" to searchable.size,
|
||||
"isDirectory" to searchable.isDirectory,
|
||||
"server" to searchable.server
|
||||
).apply {
|
||||
for ((k, v) in searchable.metaData) {
|
||||
put(
|
||||
when (k) {
|
||||
R.string.file_meta_owner -> "owner"
|
||||
else -> "other"
|
||||
}, v
|
||||
)
|
||||
}
|
||||
}.toString()
|
||||
}
|
||||
|
||||
override val typePrefix: String
|
||||
get() = "owncloud"
|
||||
}
|
||||
|
||||
class OwncloudFileDeserializer : SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable {
|
||||
val json = JSONObject(serialized)
|
||||
val id = json.getLong("id")
|
||||
val label = json.getString("label")
|
||||
val path = json.getString("path")
|
||||
val mimeType = json.getString("mimeType")
|
||||
val size = json.getLong("size")
|
||||
val isDirectory = json.getBoolean("isDirectory")
|
||||
val server = json.getString("server")
|
||||
val owner = json.optString("owner").takeIf { it.isNotEmpty() }
|
||||
|
||||
return OwncloudFile(
|
||||
fileId = id,
|
||||
label = label,
|
||||
path = path,
|
||||
mimeType = mimeType,
|
||||
size = size,
|
||||
isDirectory = isDirectory,
|
||||
server = server,
|
||||
metaData = owner?.let { listOf(R.string.file_meta_owner to it) } ?: emptyList()
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package de.mm20.launcher2.files
|
||||
|
||||
import android.content.Context
|
||||
import de.mm20.launcher2.files.providers.FileProvider
|
||||
import de.mm20.launcher2.files.providers.GDriveFileProvider
|
||||
import de.mm20.launcher2.files.providers.LocalFileProvider
|
||||
import de.mm20.launcher2.files.providers.NextcloudFileProvider
|
||||
import de.mm20.launcher2.files.providers.OneDriveFileProvider
|
||||
import de.mm20.launcher2.files.providers.OwncloudFileProvider
|
||||
import de.mm20.launcher2.nextcloud.NextcloudApiHelper
|
||||
import de.mm20.launcher2.owncloud.OwncloudClient
|
||||
import de.mm20.launcher2.permissions.PermissionsManager
|
||||
import de.mm20.launcher2.search.data.File
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
interface FileRepository {
|
||||
fun search(
|
||||
query: String,
|
||||
local: Boolean = true,
|
||||
gdrive: Boolean = true,
|
||||
onedrive: Boolean = true,
|
||||
nextcloud: Boolean = true,
|
||||
owncloud: Boolean = true,
|
||||
): Flow<ImmutableList<File>>
|
||||
|
||||
fun deleteFile(file: File)
|
||||
}
|
||||
|
||||
internal class FileRepositoryImpl(
|
||||
private val context: Context,
|
||||
private val permissionsManager: PermissionsManager,
|
||||
) : FileRepository {
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
|
||||
private val nextcloudClient by lazy {
|
||||
NextcloudApiHelper(context)
|
||||
}
|
||||
private val owncloudClient by lazy {
|
||||
OwncloudClient(context)
|
||||
}
|
||||
|
||||
override fun search(
|
||||
query: String,
|
||||
local: Boolean,
|
||||
gdrive: Boolean,
|
||||
onedrive: Boolean,
|
||||
nextcloud: Boolean,
|
||||
owncloud: Boolean
|
||||
) = channelFlow {
|
||||
if (query.isBlank()) {
|
||||
send(persistentListOf())
|
||||
return@channelFlow
|
||||
}
|
||||
|
||||
val providers = mutableListOf<FileProvider>()
|
||||
|
||||
if (local) providers.add(LocalFileProvider(context, permissionsManager))
|
||||
if (gdrive) providers.add(GDriveFileProvider(context))
|
||||
if (onedrive) providers.add(OneDriveFileProvider(context))
|
||||
if (nextcloud) providers.add(NextcloudFileProvider(nextcloudClient))
|
||||
if (owncloud) providers.add(OwncloudFileProvider(owncloudClient))
|
||||
|
||||
if (providers.isEmpty()) {
|
||||
send(persistentListOf())
|
||||
return@channelFlow
|
||||
}
|
||||
val results = mutableListOf<File>()
|
||||
for (provider in providers) {
|
||||
results.addAll(provider.search(query))
|
||||
send(results.toImmutableList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteFile(file: File) {
|
||||
scope.launch {
|
||||
if (file.isDeletable) {
|
||||
file.delete(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.mm20.launcher2.files
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val filesModule = module {
|
||||
single<FileRepository> { FileRepositoryImpl(androidContext(), get()) }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
import de.mm20.launcher2.search.data.File
|
||||
|
||||
interface FileProvider {
|
||||
suspend fun search(query: String): List<File>
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
import android.content.Context
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.gservices.DriveFileMeta
|
||||
import de.mm20.launcher2.gservices.GoogleApiHelper
|
||||
import de.mm20.launcher2.search.data.File
|
||||
import de.mm20.launcher2.search.data.GDriveFile
|
||||
|
||||
internal class GDriveFileProvider(
|
||||
private val context: Context
|
||||
) : FileProvider {
|
||||
override suspend fun search(query: String): List<File> {
|
||||
if (query.length < 4) return emptyList()
|
||||
val driveFiles = GoogleApiHelper.getInstance(context).queryGDriveFiles(query)
|
||||
return driveFiles.map {
|
||||
GDriveFile(
|
||||
fileId = it.fileId,
|
||||
label = it.label,
|
||||
size = it.size,
|
||||
mimeType = it.mimeType,
|
||||
isDirectory = it.isDirectory,
|
||||
path = "",
|
||||
directoryColor = it.directoryColor,
|
||||
viewUri = it.viewUri,
|
||||
metaData = getMetadata(it.metadata)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMetadata(file: DriveFileMeta): List<Pair<Int, String>> {
|
||||
val metaData = mutableListOf<Pair<Int, String>>()
|
||||
val owners = file.owners
|
||||
metaData.add(R.string.file_meta_owner to owners.joinToString(separator = ", "))
|
||||
val width = file.width ?: file.width
|
||||
val height = file.height ?: file.height
|
||||
if (width != null && height != null) metaData.add(R.string.file_meta_dimensions to "${width}x$height")
|
||||
return metaData
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.database.getStringOrNull
|
||||
import de.mm20.launcher2.permissions.PermissionGroup
|
||||
import de.mm20.launcher2.permissions.PermissionsManager
|
||||
import de.mm20.launcher2.search.data.File
|
||||
import de.mm20.launcher2.search.data.LocalFile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class LocalFileProvider(
|
||||
private val context: Context,
|
||||
private val permissionsManager: PermissionsManager
|
||||
): FileProvider {
|
||||
override suspend fun search(query: String): List<File> = withContext(Dispatchers.IO) {
|
||||
if (!permissionsManager.checkPermissionOnce(PermissionGroup.ExternalStorage)) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
val results = mutableListOf<LocalFile>()
|
||||
val uri = MediaStore.Files.getContentUri("external").buildUpon()
|
||||
.appendQueryParameter("limit", "10").build()
|
||||
val projection = arrayOf(
|
||||
MediaStore.Files.FileColumns.DISPLAY_NAME,
|
||||
MediaStore.Files.FileColumns._ID,
|
||||
MediaStore.Files.FileColumns.SIZE,
|
||||
MediaStore.Files.FileColumns.DATA,
|
||||
MediaStore.Files.FileColumns.MIME_TYPE
|
||||
)
|
||||
val selection =
|
||||
if (query.length > 3) "${MediaStore.Files.FileColumns.TITLE} LIKE ?" else "${MediaStore.Files.FileColumns.TITLE} = ?"
|
||||
val selArgs = if (query.length > 3) arrayOf("%$query%") else arrayOf(query)
|
||||
val sort = "${MediaStore.Files.FileColumns.DISPLAY_NAME} COLLATE NOCASE ASC"
|
||||
|
||||
|
||||
val cursor = context.contentResolver.query(uri, projection, selection, selArgs, sort)
|
||||
?: return@withContext results
|
||||
while (cursor.moveToNext()) {
|
||||
if (results.size >= 10) {
|
||||
break
|
||||
}
|
||||
val path = cursor.getString(3)
|
||||
if (!java.io.File(path).exists()) continue
|
||||
val directory = java.io.File(path).isDirectory
|
||||
val mimeType = (cursor.getStringOrNull(4)
|
||||
?: if (directory) "resource/folder" else LocalFile.getMimetypeByFileExtension(
|
||||
path.substringAfterLast(
|
||||
'.'
|
||||
)
|
||||
))
|
||||
val file = LocalFile(
|
||||
path = path,
|
||||
mimeType = mimeType,
|
||||
size = cursor.getLong(2),
|
||||
isDirectory = directory,
|
||||
id = cursor.getLong(1),
|
||||
metaData = LocalFile.getMetaData(context, mimeType, path)
|
||||
)
|
||||
results.add(file)
|
||||
}
|
||||
cursor.close()
|
||||
return@withContext results
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.nextcloud.NextcloudApiHelper
|
||||
import de.mm20.launcher2.search.data.File
|
||||
import de.mm20.launcher2.search.data.NextcloudFile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.min
|
||||
|
||||
internal class NextcloudFileProvider(
|
||||
private val nextcloudClient: NextcloudApiHelper
|
||||
) : FileProvider {
|
||||
override suspend fun search(query: String): List<File> {
|
||||
if (query.length < 4) return emptyList()
|
||||
val server = nextcloudClient.getServer() ?: return emptyList()
|
||||
return withContext(Dispatchers.IO) {
|
||||
nextcloudClient.files.search(query).let { it.subList(0, min(10, it.size)) }.map {
|
||||
NextcloudFile(
|
||||
fileId = it.id,
|
||||
label = it.name,
|
||||
path = server + it.url,
|
||||
mimeType = it.mimeType,
|
||||
size = it.size,
|
||||
isDirectory = it.isDirectory,
|
||||
server = server,
|
||||
metaData = it.owner?.let { listOf(R.string.file_meta_owner to it) }
|
||||
?: emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
import android.content.Context
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.msservices.DriveItem
|
||||
import de.mm20.launcher2.msservices.MicrosoftGraphApiHelper
|
||||
import de.mm20.launcher2.search.data.File
|
||||
import de.mm20.launcher2.search.data.OneDriveFile
|
||||
|
||||
internal class OneDriveFileProvider(
|
||||
private val context: Context
|
||||
) : FileProvider {
|
||||
override suspend fun search(query: String): List<File> {
|
||||
if (query.length < 4) return emptyList()
|
||||
val driveItems = MicrosoftGraphApiHelper.getInstance(context).queryOneDriveFiles(query)
|
||||
?: return emptyList()
|
||||
val files = mutableListOf<OneDriveFile>()
|
||||
for (driveItem in driveItems) {
|
||||
files += OneDriveFile(
|
||||
fileId = driveItem.id,
|
||||
label = driveItem.label,
|
||||
path = "",
|
||||
mimeType = driveItem.mimeType,
|
||||
size = driveItem.size,
|
||||
isDirectory = driveItem.isDirectory,
|
||||
metaData = getMetaData(driveItem),
|
||||
webUrl = driveItem.webUrl
|
||||
)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
private fun getMetaData(driveItem: DriveItem): List<Pair<Int, String>> {
|
||||
val metaData = mutableListOf<Pair<Int, String>>()
|
||||
driveItem.meta.owner?.let {
|
||||
metaData.add(R.string.file_meta_owner to it)
|
||||
} ?: driveItem.meta.createdBy?.let {
|
||||
metaData.add(R.string.file_meta_owner to it)
|
||||
}
|
||||
val width = driveItem.meta.width
|
||||
val height = driveItem.meta.height
|
||||
|
||||
if (width != null && height != null) {
|
||||
metaData.add(R.string.file_meta_dimensions to "${width}x${height}")
|
||||
}
|
||||
return metaData
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.owncloud.OwncloudClient
|
||||
import de.mm20.launcher2.search.data.File
|
||||
import de.mm20.launcher2.search.data.OwncloudFile
|
||||
|
||||
internal class OwncloudFileProvider(
|
||||
private val owncloudClient: OwncloudClient
|
||||
) : FileProvider {
|
||||
override suspend fun search(query: String): List<File> {
|
||||
if (query.length < 4) return emptyList()
|
||||
val server = owncloudClient.getServer() ?: return emptyList()
|
||||
return owncloudClient.files.query(query).map {
|
||||
OwncloudFile(
|
||||
fileId = it.id,
|
||||
label = it.name,
|
||||
path = server + it.url,
|
||||
mimeType = it.mimeType,
|
||||
size = it.size,
|
||||
isDirectory = it.isDirectory,
|
||||
server = server,
|
||||
metaData = it.owner?.let { listOf(R.string.file_meta_owner to it) } ?: emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.mm20.launcher2.media
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.media.ThumbnailUtils
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.provider.MediaStore
|
||||
import android.util.Size
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
object ThumbnailUtilsCompat {
|
||||
fun createVideoThumbnail(file: File, size: Size, signal: CancellationSignal? = null): Bitmap? {
|
||||
return try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ThumbnailUtils.createVideoThumbnail(file, size, signal)
|
||||
} else {
|
||||
ThumbnailUtils.createVideoThumbnail(file.absolutePath,
|
||||
MediaStore.Video.Thumbnails.MICRO_KIND)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.icons.ColorLayer
|
||||
import de.mm20.launcher2.icons.StaticLauncherIcon
|
||||
import de.mm20.launcher2.icons.TintedIconLayer
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import java.util.*
|
||||
|
||||
interface File : SavableSearchable {
|
||||
val path: String
|
||||
val mimeType: String
|
||||
val size: Long
|
||||
val isDirectory: Boolean
|
||||
val metaData: List<Pair<Int, String>>
|
||||
|
||||
val isStoredInCloud: Boolean
|
||||
|
||||
override val preferDetailsOverLaunch: Boolean
|
||||
get() = false
|
||||
|
||||
open val providerIconRes: Int?
|
||||
get() = null
|
||||
|
||||
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
|
||||
val (resId, bgColor) = when {
|
||||
isDirectory -> R.drawable.ic_file_folder to R.color.lightblue
|
||||
mimeType.startsWith("image/") -> R.drawable.ic_file_picture to R.color.teal
|
||||
mimeType.startsWith("audio/") -> R.drawable.ic_file_music to R.color.orange
|
||||
mimeType.startsWith("video/") -> R.drawable.ic_file_video to R.color.purple
|
||||
else -> when (mimeType) {
|
||||
"application/zip", "application/x-gtar", "application/x-tar",
|
||||
"application/java-archive", "application/x-7z-compressed",
|
||||
"application/x-compressed-tar", "application/x-gzip", "application/x-bzip2" -> R.drawable.ic_file_archive to R.color.brown
|
||||
"application/pdf" -> R.drawable.ic_file_pdf to R.color.red
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword", "text/plain", "application/vnd.google-apps.document" -> R.drawable.ic_file_document to R.color.blue
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel", "application/vnd.google-apps.spreadsheet" -> R.drawable.ic_file_spreadsheet to R.color.green
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.ms-powerpoint", "application/vnd.google-apps.presentation" -> R.drawable.ic_file_presentation to R.color.amber
|
||||
"text/x-asm", "text/x-c", "text/x-java-source", "text/x-script.phyton", "text/x-pascal",
|
||||
"text/x-script.perl", "text/javascript", "application/json" -> R.drawable.ic_file_code to R.color.pink
|
||||
"text/xml", "text/html" -> R.drawable.ic_file_markup to R.color.deeporange
|
||||
"application/vnd.android.package-archive" -> R.drawable.ic_file_android to R.color.lightgreen
|
||||
"application/vnd.google-apps.form" -> R.drawable.ic_file_form to R.color.deeppurple
|
||||
"application/vnd.google-apps.drawing" -> R.drawable.ic_file_picture to R.color.teal
|
||||
else -> R.drawable.ic_file_generic to R.color.bluegrey
|
||||
}
|
||||
}
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = TintedIconLayer(
|
||||
icon = ContextCompat.getDrawable(context, resId)!!,
|
||||
scale = 0.5f,
|
||||
color = ContextCompat.getColor(context, bgColor)
|
||||
),
|
||||
backgroundLayer = ColorLayer(ContextCompat.getColor(context, bgColor))
|
||||
)
|
||||
}
|
||||
|
||||
fun getFileType(context: Context): String {
|
||||
if (isDirectory) return context.getString(R.string.file_type_directory)
|
||||
if (mimeType == "application/vendor.de.mm20.launcher2.backup") {
|
||||
return context.getString(
|
||||
R.string.file_type_launcherbackup,
|
||||
context.getString(R.string.app_name)
|
||||
)
|
||||
}
|
||||
val resource = when (mimeType) {
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
"application/x-gtar",
|
||||
"application/x-tar",
|
||||
"application/java-archive",
|
||||
"application/x-7z-compressed" -> R.string.file_type_archive
|
||||
"application/x-gzip",
|
||||
"application/x-bzip2" -> R.string.file_type_compressed
|
||||
"application/vnd.android.package-archive" -> R.string.file_type_android
|
||||
"text/x-asm",
|
||||
"text/x-c",
|
||||
"text/x-java-source",
|
||||
"text/x-script.phyton",
|
||||
"text/x-pascal",
|
||||
"text/x-script.perl",
|
||||
"text/javascript",
|
||||
"application/json" -> R.string.file_type_source_code
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"application/x-iwork-pages-sffpages",
|
||||
"application/vnd.apple.pages",
|
||||
"application/vnd.google-apps.document" -> R.string.file_type_document
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel",
|
||||
"application/x-iwork-numbers-sffnumbers",
|
||||
"application/vnd.apple.numbers",
|
||||
"application/vnd.google-apps.spreadsheet" -> R.string.file_type_spreadsheet
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/x-iwork-keynote-sffkey",
|
||||
"application/vnd.apple.keynote",
|
||||
"application/vnd.google-apps.presentation" -> R.string.file_type_presentation
|
||||
"text/plain" -> R.string.file_type_text
|
||||
"application/vnd.google-apps.drawing" -> R.string.file_type_drawing
|
||||
"application/vnd.google-apps.form" -> R.string.file_type_form
|
||||
"application/epub+zip" -> R.string.file_type_ebook
|
||||
else -> when {
|
||||
mimeType.startsWith("image/") -> R.string.file_type_image
|
||||
mimeType.startsWith("video/") -> R.string.file_type_video
|
||||
mimeType.startsWith("audio/") -> R.string.file_type_music
|
||||
else -> R.string.file_type_none
|
||||
}
|
||||
}
|
||||
if (resource == R.string.file_type_none && label.matches(Regex(".+\\..+"))) {
|
||||
val extension = label.substringAfterLast(".").uppercase(Locale.getDefault())
|
||||
if (extension == "kvaesitso") return context.getString(
|
||||
R.string.file_type_launcherbackup,
|
||||
context.getString(R.string.app_name)
|
||||
)
|
||||
return context.getString(R.string.file_type_generic, extension)
|
||||
}
|
||||
return context.getString(resource)
|
||||
}
|
||||
|
||||
val isDeletable: Boolean
|
||||
get() = false
|
||||
suspend fun delete(context: Context) {}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
|
||||
data class GDriveFile(
|
||||
val fileId: String,
|
||||
override val label: String,
|
||||
override val path: String,
|
||||
override val mimeType: String,
|
||||
override val size: Long,
|
||||
override val isDirectory: Boolean,
|
||||
override val metaData: List<Pair<Int, String>>,
|
||||
val directoryColor: String?,
|
||||
val viewUri: String,
|
||||
override val labelOverride: String? = null,
|
||||
) : File {
|
||||
|
||||
override fun overrideLabel(label: String): GDriveFile {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
|
||||
override val domain: String = Domain
|
||||
|
||||
override val key: String = "$domain://$fileId"
|
||||
|
||||
override val isStoredInCloud = true
|
||||
|
||||
override val providerIconRes = R.drawable.ic_badge_gdrive
|
||||
|
||||
private fun getLaunchIntent(): Intent {
|
||||
return Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(viewUri)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
}
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
return context.tryStartActivity(getLaunchIntent(), options)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "gdrive"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.AdaptiveIconDrawable
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.location.Geocoder
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.ThumbnailUtils
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.text.format.DateUtils
|
||||
import android.util.Size
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.icons.*
|
||||
import de.mm20.launcher2.ktx.formatToString
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.media.ThumbnailUtilsCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.io.File as JavaIOFile
|
||||
|
||||
data class LocalFile(
|
||||
val id: Long,
|
||||
override val path: String,
|
||||
override val mimeType: String,
|
||||
override val size: Long,
|
||||
override val isDirectory: Boolean,
|
||||
override val metaData: List<Pair<Int, String>>,
|
||||
override val labelOverride: String? = null
|
||||
) : File {
|
||||
|
||||
override val label = path.substringAfterLast('/')
|
||||
|
||||
override fun overrideLabel(label: String): LocalFile {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
|
||||
override val domain: String = Domain
|
||||
|
||||
override val key = "$domain://$path"
|
||||
|
||||
override val isStoredInCloud = false
|
||||
|
||||
override suspend fun loadIcon(
|
||||
context: Context,
|
||||
size: Int,
|
||||
themed: Boolean,
|
||||
): LauncherIcon? {
|
||||
if (!JavaIOFile(path).exists()) return null
|
||||
when {
|
||||
mimeType.startsWith("image/") -> {
|
||||
val thumbnail = withContext(Dispatchers.IO) {
|
||||
ThumbnailUtils.extractThumbnail(
|
||||
BitmapFactory.decodeFile(path),
|
||||
size, size
|
||||
)
|
||||
} ?: return null
|
||||
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = StaticIconLayer(
|
||||
icon = BitmapDrawable(context.resources, thumbnail),
|
||||
scale = 1f,
|
||||
),
|
||||
backgroundLayer = ColorLayer()
|
||||
)
|
||||
}
|
||||
mimeType.startsWith("video/") -> {
|
||||
val thumbnail = withContext(Dispatchers.IO) {
|
||||
ThumbnailUtilsCompat.createVideoThumbnail(
|
||||
JavaIOFile(path),
|
||||
Size(size, size)
|
||||
)
|
||||
} ?: return null
|
||||
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = StaticIconLayer(
|
||||
icon = BitmapDrawable(context.resources, thumbnail),
|
||||
scale = 1f,
|
||||
),
|
||||
backgroundLayer = ColorLayer()
|
||||
)
|
||||
}
|
||||
mimeType.startsWith("audio/") -> {
|
||||
val thumbnail = withContext(Dispatchers.IO) {
|
||||
val mediaMetadataRetriever = MediaMetadataRetriever()
|
||||
try {
|
||||
mediaMetadataRetriever.setDataSource(path)
|
||||
val thumbData = mediaMetadataRetriever.embeddedPicture
|
||||
if (thumbData != null) {
|
||||
val thumbnail = ThumbnailUtils.extractThumbnail(
|
||||
BitmapFactory.decodeByteArray(thumbData, 0, thumbData.size),
|
||||
size,
|
||||
size
|
||||
)
|
||||
mediaMetadataRetriever.release()
|
||||
return@withContext thumbnail
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
}
|
||||
mediaMetadataRetriever.release()
|
||||
return@withContext null
|
||||
|
||||
}
|
||||
thumbnail ?: return null
|
||||
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = StaticIconLayer(
|
||||
icon = BitmapDrawable(context.resources, thumbnail),
|
||||
scale = 1f,
|
||||
),
|
||||
backgroundLayer = ColorLayer()
|
||||
)
|
||||
|
||||
}
|
||||
mimeType == "application/vnd.android.package-archive" -> {
|
||||
val pkgInfo = context.packageManager.getPackageArchiveInfo(path, 0)
|
||||
val icon = withContext(Dispatchers.IO) {
|
||||
pkgInfo?.applicationInfo?.loadIcon(context.packageManager)
|
||||
} ?: return null
|
||||
when (icon) {
|
||||
is AdaptiveIconDrawable -> {
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = icon.foreground?.let {
|
||||
StaticIconLayer(
|
||||
icon = it,
|
||||
scale = 1.5f,
|
||||
)
|
||||
} ?: TransparentLayer,
|
||||
backgroundLayer = icon.background?.let {
|
||||
StaticIconLayer(
|
||||
icon = it,
|
||||
scale = 1.5f,
|
||||
)
|
||||
} ?: TransparentLayer,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
return StaticLauncherIcon(
|
||||
foregroundLayer = StaticIconLayer(
|
||||
icon = icon,
|
||||
scale = 0.7f,
|
||||
),
|
||||
backgroundLayer = ColorLayer()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
private fun getLaunchIntent(context: Context): Intent {
|
||||
val uri = if (isDirectory) {
|
||||
Uri.parse(path)
|
||||
} else {
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
context.applicationContext.packageName + ".fileprovider", JavaIOFile(path)
|
||||
)
|
||||
}
|
||||
return Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(uri, mimeType)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
return context.tryStartActivity(getLaunchIntent(context), options)
|
||||
}
|
||||
|
||||
override val isDeletable: Boolean
|
||||
get() {
|
||||
val file = java.io.File(path)
|
||||
return file.canWrite() && file.parentFile?.canWrite() == true
|
||||
}
|
||||
|
||||
override suspend fun delete(context: Context) {
|
||||
super.delete(context)
|
||||
|
||||
val file = java.io.File(path)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
file.deleteRecursively()
|
||||
|
||||
context.contentResolver.delete(
|
||||
MediaStore.Files.getContentUri("external"),
|
||||
"${MediaStore.Files.FileColumns._ID} = ?",
|
||||
arrayOf(id.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
const val Domain = "file"
|
||||
|
||||
internal fun getMimetypeByFileExtension(extension: String): String {
|
||||
return when (extension) {
|
||||
"apk" -> "application/vnd.android.package-archive"
|
||||
"zip" -> "application/zip"
|
||||
"jar" -> "application/java-archive"
|
||||
"txt" -> "text/plain"
|
||||
"js" -> "text/javascript"
|
||||
"html", "htm" -> "text/html"
|
||||
"css" -> "text/css"
|
||||
"gif" -> "image/gif"
|
||||
"png" -> "image/png"
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"bmp" -> "image/bmp"
|
||||
"webp" -> "image/webp"
|
||||
"ico" -> "image/x-icon"
|
||||
"midi" -> "audio/midi"
|
||||
"mp3" -> "audio/mpeg3"
|
||||
"webm" -> "audio/webm"
|
||||
"ogg" -> "audio/ogg"
|
||||
"wav" -> "audio/wav"
|
||||
"mp4" -> "video/mp4"
|
||||
"kvaesitso" -> "application/vendor.de.mm20.launcher2.backup"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal fun getMetaData(
|
||||
context: Context,
|
||||
mimeType: String,
|
||||
path: String
|
||||
): List<Pair<Int, String>> {
|
||||
val metaData = mutableListOf<Pair<Int, String>>()
|
||||
when {
|
||||
mimeType.startsWith("audio/") -> {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(path)
|
||||
arrayOf(
|
||||
R.string.file_meta_title to MediaMetadataRetriever.METADATA_KEY_TITLE,
|
||||
R.string.file_meta_artist to MediaMetadataRetriever.METADATA_KEY_ARTIST,
|
||||
R.string.file_meta_album to MediaMetadataRetriever.METADATA_KEY_ALBUM,
|
||||
R.string.file_meta_year to MediaMetadataRetriever.METADATA_KEY_YEAR
|
||||
).forEach {
|
||||
retriever.extractMetadata(it.second)
|
||||
?.let { m -> metaData.add(it.first to m) }
|
||||
}
|
||||
val duration =
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLong() ?: 0
|
||||
val d = DateUtils.formatElapsedTime((duration) / 1000)
|
||||
metaData.add(3, R.string.file_meta_duration to d)
|
||||
retriever.release()
|
||||
} catch (e: RuntimeException) {
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
mimeType.startsWith("video/") -> {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(path)
|
||||
val width =
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
?.toLong() ?: 0
|
||||
val height =
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
?.toLong() ?: 0
|
||||
metaData.add(R.string.file_meta_dimensions to "${width}x$height")
|
||||
val duration =
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLong() ?: 0
|
||||
val d = DateUtils.formatElapsedTime(duration / 1000)
|
||||
metaData.add(R.string.file_meta_duration to d)
|
||||
val loc =
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
||||
if (Geocoder.isPresent() && loc != null) {
|
||||
val lon =
|
||||
loc.substring(0, loc.lastIndexOfAny(charArrayOf('+', '-')))
|
||||
.toDouble()
|
||||
val lat = loc.substring(
|
||||
loc.lastIndexOfAny(charArrayOf('+', '-')),
|
||||
loc.indexOf('/')
|
||||
).toDouble()
|
||||
val list = Geocoder(context).getFromLocation(lon, lat, 1)
|
||||
if (list != null && list.size > 0) {
|
||||
metaData.add(R.string.file_meta_location to list[0].formatToString())
|
||||
}
|
||||
}
|
||||
retriever.release()
|
||||
} catch (e: RuntimeException) {
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
mimeType.startsWith("image/") -> {
|
||||
val options = BitmapFactory.Options()
|
||||
options.inJustDecodeBounds = true
|
||||
BitmapFactory.decodeFile(path, options)
|
||||
val width = options.outWidth
|
||||
val height = options.outHeight
|
||||
metaData.add(R.string.file_meta_dimensions to "${width}x$height")
|
||||
try {
|
||||
val exif = ExifInterface(path)
|
||||
val loc = exif.latLong
|
||||
if (loc != null && Geocoder.isPresent()) {
|
||||
val list = Geocoder(context).getFromLocation(loc[0], loc[1], 1)
|
||||
if (list != null && list.size > 0) {
|
||||
metaData.add(R.string.file_meta_location to list[0].formatToString())
|
||||
}
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
|
||||
}
|
||||
}
|
||||
mimeType == "application/vnd.android.package-archive" -> {
|
||||
val pkgInfo = context.packageManager.getPackageArchiveInfo(path, 0)
|
||||
?: return metaData
|
||||
metaData.add(
|
||||
R.string.file_meta_app_name to pkgInfo.applicationInfo.loadLabel(
|
||||
context.packageManager
|
||||
).toString()
|
||||
)
|
||||
metaData.add(R.string.file_meta_app_pkgname to pkgInfo.packageName)
|
||||
metaData.add(R.string.file_meta_app_version to pkgInfo.versionName)
|
||||
metaData.add(R.string.file_meta_app_min_sdk to pkgInfo.applicationInfo.minSdkVersion.toString())
|
||||
}
|
||||
}
|
||||
return metaData
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
|
||||
data class NextcloudFile(
|
||||
val fileId: Long,
|
||||
override val label: String,
|
||||
override val path: String,
|
||||
override val mimeType: String,
|
||||
override val size: Long,
|
||||
override val isDirectory: Boolean,
|
||||
val server: String,
|
||||
override val metaData: List<Pair<Int, String>>,
|
||||
override val labelOverride: String? = null,
|
||||
) : File {
|
||||
|
||||
override fun overrideLabel(label: String): NextcloudFile {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
|
||||
override val domain: String = Domain
|
||||
|
||||
override val key: String = "$domain://$server/$fileId"
|
||||
|
||||
override val isStoredInCloud: Boolean
|
||||
get() = true
|
||||
|
||||
override val providerIconRes = R.drawable.ic_badge_nextcloud
|
||||
|
||||
private fun getLaunchIntent(context: Context): Intent {
|
||||
return Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse("$server/f/$fileId")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
`package` = getNextcloudAppPackage(context)
|
||||
}
|
||||
}
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
return context.tryStartActivity(getLaunchIntent(context), options)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val Domain = "nextcloud"
|
||||
private fun getNextcloudAppPackage(context: Context): String? {
|
||||
val candidates = listOf("com.nextcloud.client", "com.nextcloud.android.beta")
|
||||
|
||||
for (c in candidates) {
|
||||
try {
|
||||
context.packageManager.getPackageInfo(c, 0)
|
||||
return c
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
|
||||
data class OneDriveFile(
|
||||
val fileId: String,
|
||||
override val label: String,
|
||||
override val path: String,
|
||||
override val mimeType: String,
|
||||
override val size: Long,
|
||||
override val isDirectory: Boolean,
|
||||
override val metaData: List<Pair<Int, String>>,
|
||||
val webUrl: String,
|
||||
override val labelOverride: String? = null,
|
||||
) : File {
|
||||
|
||||
override fun overrideLabel(label: String): OneDriveFile {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
|
||||
override val domain: String = Domain
|
||||
|
||||
override val key: String = "$domain://$fileId"
|
||||
|
||||
override val providerIconRes = R.drawable.ic_badge_onedrive
|
||||
|
||||
override val isStoredInCloud = true
|
||||
|
||||
private fun getLaunchIntent(): Intent {
|
||||
return Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(webUrl)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
}
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
return context.tryStartActivity(getLaunchIntent(), options)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "onedrive"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
|
||||
data class OwncloudFile(
|
||||
val fileId: Long,
|
||||
override val label: String,
|
||||
override val path: String,
|
||||
override val mimeType: String,
|
||||
override val size: Long,
|
||||
override val isDirectory: Boolean,
|
||||
val server: String,
|
||||
override val metaData: List<Pair<Int, String>>,
|
||||
override val labelOverride: String? = null,
|
||||
) : File {
|
||||
|
||||
override fun overrideLabel(label: String): OwncloudFile {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
|
||||
override val domain: String = Domain
|
||||
|
||||
override val key: String = "$domain://$server/$fileId"
|
||||
|
||||
override val isStoredInCloud: Boolean
|
||||
get() = true
|
||||
|
||||
override val providerIconRes = R.drawable.ic_badge_owncloud
|
||||
|
||||
private fun getLaunchIntent(): Intent {
|
||||
return Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse("$server/f/$fileId")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
}
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
return context.tryStartActivity(getLaunchIntent(), options)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "owncloud"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,47 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = sdk.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = sdk.versions.minSdk.get().toInt()
|
||||
targetSdk = sdk.versions.targetSdk.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
namespace = "de.mm20.launcher2.notifications"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.bundles.kotlin)
|
||||
implementation(libs.androidx.core)
|
||||
|
||||
implementation(libs.bundles.androidx.lifecycle)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
|
||||
implementation(project(":core:permissions"))
|
||||
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user