Annual refactor
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package de.mm20.launcher2.applications
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.os.UserHandle
|
||||
import de.mm20.launcher2.search.AppProfile
|
||||
import de.mm20.launcher2.search.Application
|
||||
import de.mm20.launcher2.search.NullSerializer
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
|
||||
class FakeApp: Application {
|
||||
override val componentName: ComponentName = ComponentName(randomString(), randomString())
|
||||
override val isSystemApp: Boolean = false
|
||||
override val isSuspended: Boolean = false
|
||||
override val profile: AppProfile = AppProfile.Personal
|
||||
override val user: UserHandle = Process.myUserHandle()
|
||||
override val versionName: String = "1.0"
|
||||
override val canUninstall: Boolean = false
|
||||
|
||||
override fun uninstall(context: Context) {
|
||||
|
||||
}
|
||||
|
||||
override fun openAppDetails(context: Context) {
|
||||
|
||||
}
|
||||
|
||||
override val canShareApk: Boolean = false
|
||||
override val key: String = "fake://${randomString()}"
|
||||
override val label: String = randomString()
|
||||
|
||||
override fun overrideLabel(label: String): SavableSearchable {
|
||||
return this
|
||||
}
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override val domain: String
|
||||
get() = "fake"
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return NullSerializer()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private fun randomString(): String {
|
||||
val charset = "abcdefghijklmnopqrstuvwxyz"
|
||||
return (1..10)
|
||||
.map { charset.random() }
|
||||
.joinToString("")
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-43
@@ -6,8 +6,8 @@ import android.content.Intent
|
||||
import android.content.pm.LauncherApps
|
||||
import android.os.Process
|
||||
import androidx.core.content.getSystemService
|
||||
import de.mm20.launcher2.ktx.getSerialNumber
|
||||
import de.mm20.launcher2.search.data.LauncherApp
|
||||
import de.mm20.launcher2.search.Application
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -16,51 +16,18 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* A fake implementation of [AppRepository] to simulate many installed apps.
|
||||
* App repository that returns a fixed number of fake apps to simulate a large number of apps.
|
||||
*/
|
||||
class FakeAppRepository(private val context: Context, private val fakePackages: Int) : AppRepository {
|
||||
class FakeAppRepository(private val context: Context, private val fakePackages: Int) : SearchableRepository<Application> {
|
||||
|
||||
|
||||
private val fakeApp: LauncherApp
|
||||
|
||||
init {
|
||||
val launcherApps = context.getSystemService<LauncherApps>()!!
|
||||
fakeApp = LauncherApp(
|
||||
context,
|
||||
launcherApps.resolveActivity(
|
||||
Intent().apply {
|
||||
component = ComponentName(
|
||||
context.packageName,
|
||||
"de.mm20.launcher2.ui.launcher.LauncherActivity"
|
||||
)
|
||||
},
|
||||
Process.myUserHandle()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun randomString(): String {
|
||||
val charset = "abcdefghijklmnopqrstuvwxyz"
|
||||
return (1..10)
|
||||
.map { charset.random() }
|
||||
.joinToString("")
|
||||
}
|
||||
|
||||
override fun getAllInstalledApps(): Flow<List<LauncherApp>> {
|
||||
return flowOf(buildList {
|
||||
repeat(fakePackages) {
|
||||
add(fakeApp.copy(`package` = randomString(), activity = randomString()))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun getSuspendedPackages(): Flow<List<String>> {
|
||||
return flowOf(emptyList())
|
||||
}
|
||||
|
||||
override fun search(query: String): Flow<ImmutableList<LauncherApp>> {
|
||||
override fun search(query: String): Flow<ImmutableList<Application>> {
|
||||
return if (query.isEmpty()) {
|
||||
getAllInstalledApps().map { it.toImmutableList() }
|
||||
buildList {
|
||||
repeat(fakePackages) {
|
||||
add(FakeApp())
|
||||
}
|
||||
}.toImmutableList().let { flowOf(it) }
|
||||
} else {
|
||||
flowOf(persistentListOf())
|
||||
}
|
||||
|
||||
@@ -10,28 +10,34 @@ 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 de.mm20.launcher2.search.Application
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
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.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.text.similarity.FuzzyScore
|
||||
import java.util.Locale
|
||||
|
||||
interface AppRepository {
|
||||
fun getAllInstalledApps(): Flow<List<LauncherApp>>
|
||||
fun getSuspendedPackages(): Flow<List<String>>
|
||||
fun search(query: String): Flow<ImmutableList<LauncherApp>>
|
||||
interface AppRepository : SearchableRepository<Application> {
|
||||
override fun search(query: String): Flow<ImmutableList<Application>>
|
||||
|
||||
fun findMany(): Flow<ImmutableList<Application>>
|
||||
}
|
||||
|
||||
internal class AppRepositoryImpl(
|
||||
private val context: Context,
|
||||
) : AppRepository {
|
||||
private val scope = CoroutineScope(Dispatchers.Default + Job())
|
||||
|
||||
private val launcherApps =
|
||||
context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
|
||||
@@ -40,9 +46,10 @@ internal class AppRepositoryImpl(
|
||||
private val suspendedPackages = MutableStateFlow<List<String>>(emptyList())
|
||||
|
||||
|
||||
private val profiles: List<UserHandle> =
|
||||
launcherApps.profiles.takeIf { it.isNotEmpty() } ?: listOf(Process.myUserHandle())
|
||||
private val profiles: List<UserHandle>
|
||||
get() = launcherApps.profiles.takeIf { it.isNotEmpty() } ?: listOf(Process.myUserHandle())
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
init {
|
||||
launcherApps.registerCallback(object : LauncherApps.Callback() {
|
||||
@@ -51,15 +58,23 @@ internal class AppRepositoryImpl(
|
||||
user: UserHandle,
|
||||
replacing: Boolean
|
||||
) {
|
||||
installedApps.value =
|
||||
installedApps.value.filter { !packageNames.contains(it.`package`) }
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
installedApps.value =
|
||||
installedApps.value.filter { !packageNames.contains(it.componentName.packageName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPackageChanged(packageName: String, user: UserHandle) {
|
||||
val apps = installedApps.value.toMutableList()
|
||||
apps.removeAll { packageName == it.`package` }
|
||||
apps.addAll(getApplications(packageName))
|
||||
installedApps.value = apps
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
val apps = installedApps.value.toMutableList()
|
||||
apps.removeAll { packageName == it.componentName.packageName }
|
||||
apps.addAll(getApplications(packageName))
|
||||
installedApps.value = apps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPackagesAvailable(
|
||||
@@ -67,23 +82,35 @@ internal class AppRepositoryImpl(
|
||||
user: UserHandle,
|
||||
replacing: Boolean
|
||||
) {
|
||||
val apps = installedApps.value.toMutableList()
|
||||
for (packageName in packageNames) {
|
||||
apps.addAll(getApplications(packageName))
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
val apps = installedApps.value.toMutableList()
|
||||
for (packageName in packageNames) {
|
||||
apps.addAll(getApplications(packageName))
|
||||
}
|
||||
installedApps.value = apps
|
||||
}
|
||||
}
|
||||
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
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
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 }
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
installedApps.value =
|
||||
installedApps.value.filter { packageName != (it.componentName.packageName) || it.user != user }
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShortcutsChanged(
|
||||
@@ -91,40 +118,55 @@ internal class AppRepositoryImpl(
|
||||
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
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
installedApps.value = installedApps.value.map {
|
||||
if (packageNames.contains(it.componentName.packageName)) {
|
||||
it.copy(isSuspended = true)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPackagesUnsuspended(
|
||||
packageNames: Array<out String>?,
|
||||
user: UserHandle?
|
||||
) {
|
||||
super.onPackagesUnsuspended(packageNames, user)
|
||||
packageNames ?: return
|
||||
suspendedPackages.value =
|
||||
suspendedPackages.value.filter { packageNames.contains(it) }
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
installedApps.value = installedApps.value.map {
|
||||
if (packageNames.contains(it.componentName.packageName)) {
|
||||
it.copy(isSuspended = false)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}, Handler(Looper.getMainLooper()))
|
||||
val apps = profiles.map { p ->
|
||||
try {
|
||||
launcherApps.getActivityList(null, p).mapNotNull { getApplication(it, p) }
|
||||
} catch (e: SecurityException) {
|
||||
emptyList()
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
val apps = profiles.map { p ->
|
||||
try {
|
||||
launcherApps.getActivityList(null, p).mapNotNull { getApplication(it, p) }
|
||||
} catch (e: SecurityException) {
|
||||
emptyList()
|
||||
}
|
||||
}.flatten()
|
||||
installedApps.value = apps
|
||||
}
|
||||
}.flatten()
|
||||
installedApps.value = apps
|
||||
}
|
||||
|
||||
|
||||
override fun getSuspendedPackages(): Flow<List<String>> {
|
||||
return suspendedPackages
|
||||
}
|
||||
}
|
||||
|
||||
private fun getApplications(packageName: String): List<LauncherApp> {
|
||||
@@ -151,6 +193,10 @@ internal class AppRepositoryImpl(
|
||||
return LauncherApp(context, launcherActivityInfo)
|
||||
}
|
||||
|
||||
override fun findMany(): Flow<ImmutableList<Application>> {
|
||||
return installedApps.map { it.toImmutableList() }
|
||||
}
|
||||
|
||||
override fun search(query: String): Flow<ImmutableList<LauncherApp>> {
|
||||
return installedApps.map { apps ->
|
||||
withContext(Dispatchers.Default) {
|
||||
@@ -171,10 +217,6 @@ internal class AppRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAllInstalledApps(): Flow<List<LauncherApp>> {
|
||||
return installedApps
|
||||
}
|
||||
|
||||
private fun matches(label: String, query: String): Boolean {
|
||||
val normalizedLabel = label.normalize()
|
||||
val normalizedQuery = query.normalize()
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.applications
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
@@ -15,8 +15,8 @@ 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("package", searchable.componentName.packageName)
|
||||
json.put("activity", searchable.componentName.className)
|
||||
json.put("user", searchable.userSerialNumber)
|
||||
return json.toString()
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class LauncherAppSerializer : SearchableSerializer {
|
||||
}
|
||||
|
||||
class LauncherAppDeserializer(val context: Context) : SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable? {
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable? {
|
||||
val json = JSONObject(serialized)
|
||||
val launcherApps = context.getSystemService<LauncherApps>()!!
|
||||
val userManager = context.getSystemService<UserManager>()!!
|
||||
+70
-59
@@ -1,9 +1,10 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.applications
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.LauncherActivityInfo
|
||||
import android.content.pm.LauncherApps
|
||||
@@ -13,43 +14,56 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.os.UserHandle
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
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.icons.ColorLayer
|
||||
import de.mm20.launcher2.icons.LauncherIcon
|
||||
import de.mm20.launcher2.icons.StaticIconLayer
|
||||
import de.mm20.launcher2.icons.StaticLauncherIcon
|
||||
import de.mm20.launcher2.icons.TintedIconLayer
|
||||
import de.mm20.launcher2.icons.TransparentLayer
|
||||
import de.mm20.launcher2.ktx.getSerialNumber
|
||||
import de.mm20.launcher2.ktx.isAtLeastApiLevel
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.AppProfile
|
||||
import de.mm20.launcher2.search.Application
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import de.mm20.launcher2.search.StoreLink
|
||||
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 data class LauncherApp(
|
||||
private val launcherActivityInfo: LauncherActivityInfo,
|
||||
override val versionName: String?,
|
||||
override val isSuspended: Boolean = false,
|
||||
internal val userSerialNumber: Long,
|
||||
override val labelOverride: String? = null,
|
||||
) : SavableSearchable {
|
||||
) : Application {
|
||||
|
||||
constructor(context: Context, launcherActivityInfo: LauncherActivityInfo): this(
|
||||
override val componentName: ComponentName
|
||||
get() = launcherActivityInfo.componentName
|
||||
|
||||
override val label: String = launcherActivityInfo.label.toString()
|
||||
|
||||
|
||||
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),
|
||||
versionName = getPackageVersionName(context, launcherActivityInfo.applicationInfo.packageName),
|
||||
userSerialNumber = launcherActivityInfo.user.getSerialNumber(context)
|
||||
)
|
||||
|
||||
val isMainProfile = launcherActivityInfo.user == Process.myUserHandle()
|
||||
override val user: UserHandle
|
||||
get() = launcherActivityInfo.user
|
||||
|
||||
val canUninstall: Boolean
|
||||
get() = flags and ApplicationInfo.FLAG_SYSTEM == 0 && isMainProfile
|
||||
private val isMainProfile = launcherActivityInfo.user == Process.myUserHandle()
|
||||
|
||||
override val profile: AppProfile
|
||||
get() = if (isMainProfile) AppProfile.Personal else AppProfile.Work
|
||||
|
||||
override val isSystemApp: Boolean = launcherActivityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM == 0
|
||||
|
||||
override val canUninstall: Boolean
|
||||
get() = !isSystemApp && isMainProfile
|
||||
|
||||
override val domain: String = Domain
|
||||
override val preferDetailsOverLaunch: Boolean = false
|
||||
@@ -59,22 +73,10 @@ data class LauncherApp(
|
||||
}
|
||||
|
||||
override val key: String
|
||||
get() = if (isMainProfile) "${domain}://$`package`:$activity" else "${domain}://$`package`:$activity:${userSerialNumber}"
|
||||
// For backwards compatibility, user serial number is not included in main profile
|
||||
get() = if (isMainProfile) "${domain}://${componentName.packageName}:${componentName.packageName}"
|
||||
else "${domain}://${componentName.packageName}:${componentName.className}:${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.65f,
|
||||
color = 0xff3dda84.toInt(),
|
||||
),
|
||||
backgroundLayer = ColorLayer(0xff3dda84.toInt())
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun loadIcon(
|
||||
context: Context,
|
||||
@@ -132,7 +134,7 @@ data class LauncherApp(
|
||||
}
|
||||
try {
|
||||
launcherApps.startMainActivity(
|
||||
ComponentName(`package`, activity),
|
||||
componentName,
|
||||
launcherActivityInfo.user,
|
||||
null,
|
||||
options
|
||||
@@ -145,11 +147,15 @@ data class LauncherApp(
|
||||
return true
|
||||
}
|
||||
|
||||
fun getStoreDetails(context: Context): StoreLink? {
|
||||
override fun getStoreDetails(context: Context): StoreLink? {
|
||||
val pm = context.packageManager
|
||||
return try {
|
||||
val installSourceInfo = PackageManagerCompat.getInstallSource(pm, `package`)
|
||||
getStoreLinkForInstaller(installSourceInfo.initiatingPackageName, `package`)
|
||||
val installSourceInfo =
|
||||
PackageManagerCompat.getInstallSource(pm, componentName.packageName)
|
||||
getStoreLinkForInstaller(
|
||||
installSourceInfo.initiatingPackageName,
|
||||
componentName.packageName
|
||||
)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
@@ -157,37 +163,33 @@ data class LauncherApp(
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstall(context: Context) {
|
||||
override fun uninstall(context: Context) {
|
||||
val intent = Intent(Intent.ACTION_DELETE)
|
||||
intent.data = Uri.parse("package:$`package`")
|
||||
intent.data = Uri.parse("package:${componentName.packageName}")
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun openAppInfo(context: Context) {
|
||||
override fun openAppDetails(context: Context) {
|
||||
val launcherApps = context.getSystemService<LauncherApps>()!!
|
||||
|
||||
launcherApps.startAppDetailsActivity(
|
||||
ComponentName(`package`, activity),
|
||||
getUser(),
|
||||
componentName,
|
||||
user,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun shareApkFile(context: Context) {
|
||||
override val canShareApk: Boolean = true
|
||||
override suspend fun shareApkFile(context: Context) {
|
||||
val launcherApps = context.getSystemService<LauncherApps>()!!
|
||||
val fileCopy = java.io.File(
|
||||
context.cacheDir,
|
||||
"${`package`}-${version}.apk"
|
||||
"${componentName.packageName}-${versionName}.apk"
|
||||
)
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val user = getUser()
|
||||
val info = if (user != null) {
|
||||
launcherApps.getApplicationInfo(`package`, 0, user)
|
||||
} else {
|
||||
context.packageManager.getApplicationInfo(`package`, 0)
|
||||
}
|
||||
val info = launcherApps.getApplicationInfo(componentName.packageName, 0, user)
|
||||
val file = java.io.File(info.publicSourceDir)
|
||||
|
||||
try {
|
||||
@@ -212,6 +214,13 @@ data class LauncherApp(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getActivityInfo(context: Context): ActivityInfo? {
|
||||
if (isAtLeastApiLevel(31)) {
|
||||
return launcherActivityInfo.activityInfo
|
||||
}
|
||||
return super.getActivityInfo(context)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun getStoreLinkForInstaller(
|
||||
installerPackage: String?,
|
||||
@@ -225,18 +234,21 @@ data class LauncherApp(
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -251,9 +263,8 @@ data class LauncherApp(
|
||||
|
||||
const val Domain = "app"
|
||||
}
|
||||
}
|
||||
|
||||
data class StoreLink(
|
||||
val label: String,
|
||||
val url: String
|
||||
)
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return LauncherAppSerializer()
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
package de.mm20.launcher2.applications
|
||||
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.Application
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val applicationsModule = module {
|
||||
single<AppRepository> { AppRepositoryImpl(androidContext()) }
|
||||
factory<SearchableRepository<Application>>(named<Application>()) { AppRepositoryImpl(androidContext()) }
|
||||
factory<AppRepository> { AppRepositoryImpl(androidContext()) }
|
||||
factory<SearchableDeserializer>(named(LauncherApp.Domain)) { LauncherAppDeserializer(androidContext()) }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.mm20.launcher2.appshortcuts
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import de.mm20.launcher2.search.AppShortcut
|
||||
|
||||
|
||||
fun AppShortcut(context: Context, pinRequestIntent: Intent): AppShortcut? {
|
||||
return LauncherShortcut.fromPinRequestIntent(context, pinRequestIntent)
|
||||
?: LegacyShortcut.fromPinRequestIntent(context, pinRequestIntent)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package de.mm20.launcher2.appshortcuts
|
||||
|
||||
import android.content.Context
|
||||
import android.content.IntentSender
|
||||
import android.content.pm.LauncherActivityInfo
|
||||
import android.content.pm.LauncherApps
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Process
|
||||
import androidx.core.content.getSystemService
|
||||
import de.mm20.launcher2.ktx.romanize
|
||||
import de.mm20.launcher2.search.AppProfile
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import java.text.Collator
|
||||
|
||||
class AppShortcutConfigActivity(
|
||||
private val launcherActivityInfo: LauncherActivityInfo,
|
||||
): Comparable<AppShortcutConfigActivity> {
|
||||
val label = launcherActivityInfo.label.toString()
|
||||
val profile: AppProfile = if (launcherActivityInfo.user == Process.myUserHandle()) AppProfile.Personal else AppProfile.Work
|
||||
|
||||
fun getIcon(context: Context): Flow<Drawable?> = flow {
|
||||
val icon = launcherActivityInfo.getIcon(context.resources.displayMetrics.densityDpi)
|
||||
emit(icon)
|
||||
}
|
||||
fun getIntent(context: Context): IntentSender? {
|
||||
val launcherApps = context.getSystemService<LauncherApps>()!!
|
||||
return launcherApps.getShortcutConfigActivityIntent(launcherActivityInfo)
|
||||
}
|
||||
|
||||
override fun compareTo(other: AppShortcutConfigActivity): Int {
|
||||
|
||||
val label1 = label
|
||||
val label2 = other.label
|
||||
return Collator.getInstance().apply { strength = Collator.SECONDARY }
|
||||
.compare(label1.romanize(), label2.romanize())
|
||||
}
|
||||
}
|
||||
+69
-67
@@ -1,21 +1,19 @@
|
||||
package de.mm20.launcher2.appshortcuts
|
||||
|
||||
import android.content.ComponentName
|
||||
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 de.mm20.launcher2.search.AppShortcut
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -28,22 +26,25 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.text.similarity.FuzzyScore
|
||||
import java.util.Locale
|
||||
|
||||
interface AppShortcutRepository {
|
||||
interface AppShortcutRepository : SearchableRepository<AppShortcut> {
|
||||
|
||||
fun search(query: String): Flow<ImmutableList<AppShortcut>>
|
||||
suspend fun getShortcutsForActivity(
|
||||
launcherActivityInfo: LauncherActivityInfo,
|
||||
count: Int = 5
|
||||
): List<LauncherShortcut>
|
||||
fun findMany(
|
||||
componentName: ComponentName? = null,
|
||||
user: UserHandle = Process.myUserHandle(),
|
||||
manifest: Boolean = false,
|
||||
dynamic: Boolean = false,
|
||||
pinned: Boolean = false,
|
||||
cached: Boolean = false,
|
||||
limit: Int = 5,
|
||||
): Flow<ImmutableList<AppShortcut>>
|
||||
|
||||
suspend fun getShortcutsConfigActivities(): List<LauncherApp>
|
||||
|
||||
fun removePinnedShortcut(shortcut: LauncherShortcut)
|
||||
suspend fun getShortcutsConfigActivities(): List<AppShortcutConfigActivity>
|
||||
}
|
||||
|
||||
internal class AppShortcutRepositoryImpl(
|
||||
@@ -53,33 +54,58 @@ internal class AppShortcutRepositoryImpl(
|
||||
|
||||
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,
|
||||
override fun findMany(
|
||||
componentName: ComponentName?,
|
||||
user: UserHandle,
|
||||
manifest: Boolean,
|
||||
dynamic: Boolean,
|
||||
pinned: Boolean,
|
||||
cached: Boolean,
|
||||
limit: Int
|
||||
): Flow<ImmutableList<AppShortcut>> = flow {
|
||||
val shortcuts = withContext(Dispatchers.IO) {
|
||||
val launcherApps = context.getSystemService<LauncherApps>()!!
|
||||
if (!launcherApps.hasShortcutHostPermission()) return@withContext emptyList()
|
||||
val query = LauncherApps.ShortcutQuery()
|
||||
.setActivity(componentName)
|
||||
.setQueryFlags(
|
||||
buildQueryFlags(manifest, dynamic, pinned, cached)
|
||||
)
|
||||
} ?: emptyList())
|
||||
appShortcuts
|
||||
val shortcuts = try {
|
||||
launcherApps.getShortcuts(query, user)
|
||||
} catch (e: IllegalStateException) {
|
||||
emptyList()
|
||||
}
|
||||
val appShortcuts = mutableListOf<LauncherShortcut>()
|
||||
appShortcuts.addAll(shortcuts
|
||||
?.let {
|
||||
if (it.size > limit) it.subList(0, limit)
|
||||
else it
|
||||
}
|
||||
?.map {
|
||||
LauncherShortcut(
|
||||
context,
|
||||
it,
|
||||
)
|
||||
} ?: emptyList()
|
||||
)
|
||||
appShortcuts
|
||||
}
|
||||
emit(shortcuts.toImmutableList())
|
||||
}
|
||||
|
||||
private fun buildQueryFlags(
|
||||
manifest: Boolean,
|
||||
dynamic: Boolean,
|
||||
pinned: Boolean,
|
||||
cached: Boolean,
|
||||
): Int {
|
||||
var flags = 0
|
||||
if (manifest) flags = flags or LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST
|
||||
if (dynamic) flags = flags or LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC
|
||||
if (pinned) flags = flags or LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED
|
||||
if (cached) flags = flags or LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED
|
||||
return flags
|
||||
}
|
||||
|
||||
override fun search(query: String) = channelFlow<ImmutableList<AppShortcut>> {
|
||||
@@ -93,7 +119,6 @@ internal class AppShortcutRepositoryImpl(
|
||||
return@withContext
|
||||
}
|
||||
|
||||
|
||||
shortcutChangeEmitter.collectLatest {
|
||||
val launcherApps =
|
||||
context.getSystemService<LauncherApps>() ?: return@collectLatest send(
|
||||
@@ -180,39 +205,16 @@ internal class AppShortcutRepositoryImpl(
|
||||
}
|
||||
}.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> {
|
||||
override suspend fun getShortcutsConfigActivities(): List<AppShortcutConfigActivity> {
|
||||
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
|
||||
if (!launcherApps.hasShortcutHostPermission()) return emptyList()
|
||||
val results = mutableListOf<LauncherApp>()
|
||||
val results = mutableListOf<AppShortcutConfigActivity>()
|
||||
val profiles = launcherApps.profiles
|
||||
for (profile in profiles) {
|
||||
val activities = launcherApps.getShortcutConfigActivityList(null, profile)
|
||||
results.addAll(
|
||||
activities.map {
|
||||
LauncherApp(
|
||||
context, it
|
||||
)
|
||||
AppShortcutConfigActivity(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+18
-13
@@ -11,9 +11,6 @@ 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 de.mm20.launcher2.search.data.UnavailableShortcut
|
||||
import org.json.JSONObject
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@@ -37,7 +34,7 @@ class LauncherShortcutDeserializer(
|
||||
val context: Context
|
||||
) : SearchableDeserializer, KoinComponent {
|
||||
|
||||
override fun deserialize(serialized: String): SavableSearchable? {
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable? {
|
||||
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
|
||||
|
||||
val json = JSONObject(serialized)
|
||||
@@ -65,16 +62,9 @@ class LauncherShortcutDeserializer(
|
||||
} 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()) {
|
||||
if (shortcuts.isNullOrEmpty()) {
|
||||
return null
|
||||
} else {
|
||||
val activity = shortcuts[0].activity
|
||||
return LauncherShortcut(
|
||||
context = context,
|
||||
launcherShortcut = shortcuts[0],
|
||||
@@ -84,6 +74,21 @@ class LauncherShortcutDeserializer(
|
||||
}
|
||||
}
|
||||
|
||||
class UnavailableShortcutSerializer: SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String? {
|
||||
searchable as UnavailableShortcut
|
||||
return jsonObjectOf(
|
||||
"packagename" to searchable.packageName,
|
||||
"id" to searchable.shortcutId,
|
||||
"user" to searchable.userSerial,
|
||||
).toString()
|
||||
}
|
||||
|
||||
override val typePrefix: String
|
||||
get() = LauncherShortcut.Domain
|
||||
|
||||
}
|
||||
|
||||
class LegacyShortcutSerializer: SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as LegacyShortcut
|
||||
@@ -106,7 +111,7 @@ class LegacyShortcutSerializer: SearchableSerializer {
|
||||
class LegacyShortcutDeserializer(
|
||||
val context: Context
|
||||
): SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable {
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable {
|
||||
val json = JSONObject(serialized)
|
||||
val label = json.getString("label")
|
||||
val intent = Intent.parseUri(json.getString("intent"), 0)
|
||||
|
||||
+40
-5
@@ -1,6 +1,7 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.appshortcuts
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.LauncherApps
|
||||
@@ -12,11 +13,13 @@ import android.os.Process
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import de.mm20.launcher2.appshortcuts.R
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.icons.*
|
||||
import de.mm20.launcher2.ktx.getSerialNumber
|
||||
import de.mm20.launcher2.ktx.isAtLeastApiLevel
|
||||
import de.mm20.launcher2.search.AppProfile
|
||||
import de.mm20.launcher2.search.AppShortcut
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.lang.NullPointerException
|
||||
@@ -24,7 +27,7 @@ import java.lang.NullPointerException
|
||||
/**
|
||||
* Represents a modern (Android O+) launcher shortcut
|
||||
*/
|
||||
data class LauncherShortcut(
|
||||
internal data class LauncherShortcut(
|
||||
val launcherShortcut: ShortcutInfo,
|
||||
override val appName: String?,
|
||||
internal val userSerialNumber: Long,
|
||||
@@ -32,6 +35,11 @@ data class LauncherShortcut(
|
||||
) : AppShortcut {
|
||||
|
||||
override val domain: String = Domain
|
||||
override val componentName: ComponentName?
|
||||
get() = launcherShortcut.activity
|
||||
|
||||
override val packageName: String
|
||||
get() = launcherShortcut.`package`
|
||||
|
||||
constructor(
|
||||
context: Context,
|
||||
@@ -48,7 +56,7 @@ data class LauncherShortcut(
|
||||
)
|
||||
|
||||
override val label: String
|
||||
get() = launcherShortcut.shortLabel?.toString() ?: ""
|
||||
get() = launcherShortcut.shortLabel?.toString() ?: launcherShortcut.longLabel?.toString() ?: ""
|
||||
|
||||
override fun overrideLabel(label: String): LauncherShortcut {
|
||||
return this.copy(labelOverride = label)
|
||||
@@ -56,8 +64,10 @@ data class LauncherShortcut(
|
||||
|
||||
override val preferDetailsOverLaunch: Boolean = false
|
||||
|
||||
private val isMainProfile = launcherShortcut.userHandle == Process.myUserHandle()
|
||||
override val profile: AppProfile
|
||||
get() = if (isMainProfile) AppProfile.Personal else AppProfile.Work
|
||||
|
||||
val isMainProfile = launcherShortcut.userHandle == Process.myUserHandle()
|
||||
|
||||
override val key: String
|
||||
get() = if (isMainProfile) {
|
||||
@@ -145,6 +155,31 @@ data class LauncherShortcut(
|
||||
)
|
||||
}
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return LauncherShortcutSerializer()
|
||||
}
|
||||
|
||||
override suspend fun delete(context: Context) {
|
||||
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 = launcherShortcut.userHandle
|
||||
val allPinned = launcherApps.getShortcuts(pinnedShortcutsQuery, userHandle)
|
||||
|
||||
if (allPinned == null) {
|
||||
Log.e("MM20", "Could not remove shortcut ${key}: shortcut query returned null")
|
||||
return
|
||||
}
|
||||
|
||||
launcherApps.pinShortcuts(
|
||||
launcherShortcut.`package`,
|
||||
allPinned.filter { it.id != launcherShortcut.id }.map { it.id },
|
||||
userHandle
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromPinRequestIntent(context: Context, data: Intent): LauncherShortcut? {
|
||||
val launcherApps =
|
||||
+17
-3
@@ -1,5 +1,6 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.appshortcuts
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.ShortcutIconResource
|
||||
@@ -10,8 +11,11 @@ import de.mm20.launcher2.icons.*
|
||||
import de.mm20.launcher2.ktx.getDrawableOrNull
|
||||
import de.mm20.launcher2.ktx.isAtLeastApiLevel
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.search.AppProfile
|
||||
import de.mm20.launcher2.search.AppShortcut
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
|
||||
data class LegacyShortcut(
|
||||
internal data class LegacyShortcut(
|
||||
val intent: Intent,
|
||||
override val label: String,
|
||||
override val appName: String?,
|
||||
@@ -22,6 +26,9 @@ data class LegacyShortcut(
|
||||
override val domain = Domain
|
||||
override val key: String = "$domain://${intent.toUri(0)}"
|
||||
|
||||
override val profile: AppProfile
|
||||
get() = AppProfile.Personal
|
||||
|
||||
override fun overrideLabel(label: String): LegacyShortcut {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
@@ -31,7 +38,10 @@ data class LegacyShortcut(
|
||||
return context.tryStartActivity(intent, options)
|
||||
}
|
||||
|
||||
val packageName: String?
|
||||
override val componentName: ComponentName?
|
||||
get() = intent.component
|
||||
|
||||
override val packageName: String?
|
||||
get() = intent.`package` ?: intent.component?.packageName
|
||||
|
||||
override suspend fun loadIcon(context: Context, size: Int, themed: Boolean): LauncherIcon? {
|
||||
@@ -75,6 +85,10 @@ data class LegacyShortcut(
|
||||
)
|
||||
}
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return LegacyShortcutSerializer()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val Domain = "legacyshortcut"
|
||||
@@ -1,8 +1,15 @@
|
||||
package de.mm20.launcher2.appshortcuts
|
||||
|
||||
import de.mm20.launcher2.search.AppShortcut
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val appShortcutsModule = module {
|
||||
single<AppShortcutRepository> { AppShortcutRepositoryImpl(androidContext(), get()) }
|
||||
factory<SearchableRepository<AppShortcut>>(named<AppShortcut>()) { AppShortcutRepositoryImpl(androidContext(), get()) }
|
||||
factory<AppShortcutRepository> { AppShortcutRepositoryImpl(androidContext(), get()) }
|
||||
factory<SearchableDeserializer>(named(LauncherShortcut.Domain)) { LauncherShortcutDeserializer(androidContext()) }
|
||||
factory<SearchableDeserializer>(named(LegacyShortcut.Domain)) { LegacyShortcutDeserializer(androidContext()) }
|
||||
}
|
||||
+17
-4
@@ -1,24 +1,27 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.appshortcuts
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.os.UserManager
|
||||
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.AppProfile
|
||||
import de.mm20.launcher2.search.AppShortcut
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
|
||||
/**
|
||||
* Shortcut class that is used when a [LauncherShortcut] is not available, e.g. missing permissions
|
||||
* when Kvaesitso is not set as default launcher.
|
||||
*/
|
||||
class UnavailableShortcut(
|
||||
internal class UnavailableShortcut(
|
||||
override val label: String,
|
||||
override val appName: String?,
|
||||
val packageName: String,
|
||||
override val packageName: String,
|
||||
val shortcutId: String,
|
||||
val isMainProfile: Boolean,
|
||||
val userSerial: Long,
|
||||
@@ -34,6 +37,8 @@ class UnavailableShortcut(
|
||||
|
||||
override val labelOverride: String?
|
||||
get() = null
|
||||
override val componentName: ComponentName?
|
||||
get() = null
|
||||
|
||||
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
|
||||
return StaticLauncherIcon(
|
||||
@@ -56,6 +61,14 @@ class UnavailableShortcut(
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return UnavailableShortcutSerializer()
|
||||
}
|
||||
|
||||
override val isUnavailable: Boolean = true
|
||||
override val profile: AppProfile
|
||||
get() = TODO("Not yet implemented")
|
||||
|
||||
companion object {
|
||||
internal operator fun invoke(context: Context, id: String, packageName: String, userSerial: Long): UnavailableShortcut? {
|
||||
val appInfo = try {
|
||||
@@ -1,36 +0,0 @@
|
||||
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.65f,
|
||||
),
|
||||
backgroundLayer = ColorLayer(0xFF3DDA84.toInt()),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromPinRequestIntent(context: Context, data: Intent): AppShortcut? {
|
||||
return LauncherShortcut.fromPinRequestIntent(context, data)
|
||||
?: LegacyShortcut.fromPinRequestIntent(context, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-31
@@ -1,4 +1,4 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.calendar
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
@@ -6,50 +6,33 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
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 de.mm20.launcher2.search.CalendarEvent
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import java.net.URLEncoder
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
data class CalendarEvent(
|
||||
internal data class AndroidCalendarEvent(
|
||||
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,
|
||||
override val color: Int,
|
||||
override val startTime: Long,
|
||||
override val endTime: Long,
|
||||
override val allDay: Boolean,
|
||||
override val location: String?,
|
||||
override val attendees: List<String>,
|
||||
override val description: String?,
|
||||
val calendar: Long,
|
||||
override val labelOverride: String? = null,
|
||||
) : SavableSearchable {
|
||||
) : CalendarEvent {
|
||||
|
||||
override val domain: String = Domain
|
||||
|
||||
override val key: String
|
||||
get() = "$domain://$id"
|
||||
|
||||
override val preferDetailsOverLaunch: Boolean = true
|
||||
|
||||
override fun overrideLabel(label: String): CalendarEvent {
|
||||
override fun overrideLabel(label: String): AndroidCalendarEvent {
|
||||
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)
|
||||
@@ -59,7 +42,8 @@ data class CalendarEvent(
|
||||
return context.tryStartActivity(getLaunchIntent(), options)
|
||||
}
|
||||
|
||||
fun openLocation(context: Context) {
|
||||
override fun openLocation(context: Context) {
|
||||
if (location == null) return
|
||||
context.tryStartActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setData(
|
||||
@@ -76,6 +60,10 @@ data class CalendarEvent(
|
||||
)
|
||||
}
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return CalendarEventSerializer()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "calendar"
|
||||
}
|
||||
@@ -6,8 +6,8 @@ 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 de.mm20.launcher2.search.CalendarEvent
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -18,16 +18,16 @@ 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>>
|
||||
interface CalendarRepository: SearchableRepository<CalendarEvent> {
|
||||
fun findMany(
|
||||
from: Long = System.currentTimeMillis(),
|
||||
to: Long = from + 14 * 24 * 60 * 60 * 1000L,
|
||||
excludeCalendars: List<Long> = emptyList(),
|
||||
excludeAllDayEvents: Boolean = false,
|
||||
limit: Int = 999,
|
||||
): Flow<ImmutableList<CalendarEvent>>
|
||||
|
||||
suspend fun getCalendars(): List<UserCalendar>
|
||||
}
|
||||
@@ -60,16 +60,43 @@ internal class CalendarRepositoryImpl(
|
||||
|
||||
}
|
||||
|
||||
override fun findMany(
|
||||
from: Long,
|
||||
to: Long,
|
||||
excludeCalendars: List<Long>,
|
||||
excludeAllDayEvents: Boolean,
|
||||
limit: Int,
|
||||
) = channelFlow<ImmutableList<CalendarEvent>> {
|
||||
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
|
||||
hasPermission.collectLatest {
|
||||
if (it) {
|
||||
val events = withContext(Dispatchers.IO) {
|
||||
queryCalendarEvents(
|
||||
query = "",
|
||||
intervalStart = from,
|
||||
intervalEnd = to,
|
||||
limit = limit,
|
||||
excludeAllDayEvents = excludeAllDayEvents,
|
||||
excludeCalendars = excludeCalendars
|
||||
)
|
||||
}
|
||||
send(events.toImmutableList())
|
||||
} else {
|
||||
send(persistentListOf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryCalendarEvents(
|
||||
query: String,
|
||||
query: String?,
|
||||
intervalStart: Long,
|
||||
intervalEnd: Long,
|
||||
limit: Int = 10,
|
||||
excludeAllDayEvents: Boolean = false,
|
||||
excludeCalendars: List<Long> = emptyList(),
|
||||
): List<CalendarEvent> {
|
||||
): List<AndroidCalendarEvent> {
|
||||
val results = withContext(Dispatchers.IO) {
|
||||
val results = mutableListOf<CalendarEvent>()
|
||||
val results = mutableListOf<AndroidCalendarEvent>()
|
||||
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
|
||||
ContentUris.appendId(builder, intervalStart)
|
||||
ContentUris.appendId(builder, intervalEnd)
|
||||
@@ -86,10 +113,10 @@ internal class CalendarRepositoryImpl(
|
||||
CalendarContract.Instances.DESCRIPTION
|
||||
)
|
||||
val selection = mutableListOf<String>()
|
||||
if (query.isNotEmpty()) selection.add("${CalendarContract.Instances.TITLE} LIKE ?")
|
||||
if (query != null) 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 selArgs = if (query != null) null else arrayOf("%$query%")
|
||||
val sort =
|
||||
"${CalendarContract.Instances.BEGIN} ASC" + if (limit > -1) " LIMIT $limit" else ""
|
||||
val cursor = context.contentResolver.query(
|
||||
@@ -128,7 +155,7 @@ internal class CalendarRepositoryImpl(
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val event = CalendarEvent(
|
||||
val event = AndroidCalendarEvent(
|
||||
label = cursor.getStringOrNull(1) ?: "",
|
||||
id = cursor.getLong(0),
|
||||
color = cursor.getInt(5),
|
||||
@@ -150,32 +177,6 @@ internal class CalendarRepositoryImpl(
|
||||
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) {
|
||||
|
||||
@@ -10,13 +10,12 @@ 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
|
||||
searchable as AndroidCalendarEvent
|
||||
val json = JSONObject()
|
||||
json.put("id", searchable.id)
|
||||
return json.toString()
|
||||
@@ -27,7 +26,7 @@ class CalendarEventSerializer: SearchableSerializer {
|
||||
}
|
||||
|
||||
class CalendarEventDeserializer(val context: Context): SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable? {
|
||||
override suspend 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")
|
||||
@@ -86,7 +85,7 @@ class CalendarEventDeserializer(val context: Context): SearchableDeserializer {
|
||||
} else {
|
||||
0
|
||||
}
|
||||
return CalendarEvent(
|
||||
return AndroidCalendarEvent(
|
||||
label = title,
|
||||
id = id,
|
||||
color = color,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package de.mm20.launcher2.calendar
|
||||
|
||||
import de.mm20.launcher2.search.CalendarEvent
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val calendarModule = module {
|
||||
single<CalendarRepository> { CalendarRepositoryImpl(androidContext(), get()) }
|
||||
factory<SearchableRepository<CalendarEvent>>(named<CalendarEvent>()) { CalendarRepositoryImpl(androidContext(), get()) }
|
||||
factory<CalendarRepository> { CalendarRepositoryImpl(androidContext(), get()) }
|
||||
factory<SearchableDeserializer>(named(AndroidCalendarEvent.Domain)) { CalendarEventDeserializer(androidContext()) }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package de.mm20.launcher2.contacts
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import de.mm20.launcher2.icons.ColorLayer
|
||||
import de.mm20.launcher2.icons.LauncherIcon
|
||||
import de.mm20.launcher2.icons.StaticIconLayer
|
||||
import de.mm20.launcher2.icons.StaticLauncherIcon
|
||||
import de.mm20.launcher2.icons.TextLayer
|
||||
import de.mm20.launcher2.ktx.asBitmap
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.search.Contact
|
||||
import de.mm20.launcher2.search.ContactInfo
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal data class AndroidContact(
|
||||
internal val id: Long,
|
||||
override val firstName: String,
|
||||
override val lastName: String,
|
||||
override val displayName: String,
|
||||
override val contactInfos: Iterable<ContactInfo>,
|
||||
internal val lookupKey: String,
|
||||
override val labelOverride: String? = null,
|
||||
) : Contact {
|
||||
|
||||
|
||||
override val domain: String = Domain
|
||||
override val key: String
|
||||
get() = "$Domain://$id"
|
||||
override val label: String
|
||||
get() = displayName.takeIf { it.isNotBlank() } ?: "$firstName $lastName"
|
||||
|
||||
override val summary: String
|
||||
get() {
|
||||
return contactInfos.distinctBy { it.label }.take(5).joinToString(separator = ", ") { it.label }
|
||||
}
|
||||
|
||||
override fun overrideLabel(label: String): Contact {
|
||||
return copy(labelOverride = label)
|
||||
}
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
val uri =
|
||||
ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id)
|
||||
val intent = Intent(Intent.ACTION_VIEW).setData(uri).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
return context.tryStartActivity(intent, options)
|
||||
}
|
||||
|
||||
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 getSerializer(): SearchableSerializer {
|
||||
return ContactSerializer()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "contact"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package de.mm20.launcher2.contacts
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import de.mm20.launcher2.search.ContactInfo
|
||||
import de.mm20.launcher2.search.ContactInfoType
|
||||
import java.net.URLEncoder
|
||||
|
||||
|
||||
internal data class PhoneContactInfo(
|
||||
val number: String,
|
||||
) : ContactInfo {
|
||||
override val label: String
|
||||
get() = number
|
||||
|
||||
override val type: ContactInfoType = ContactInfoType.Phone
|
||||
|
||||
override val intent: Intent
|
||||
get() = Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse("tel:$number"))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
internal data class MailContactInfo(
|
||||
val address: String,
|
||||
) : ContactInfo {
|
||||
override val label: String
|
||||
get() = address
|
||||
|
||||
override val type: ContactInfoType = ContactInfoType.Email
|
||||
|
||||
override val intent: Intent
|
||||
get() = Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse("mailto:$address"))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
internal data class PostalContactInfo(
|
||||
val address: String,
|
||||
) : ContactInfo {
|
||||
override val label: String
|
||||
get() = address.replace("\n", ", ")
|
||||
|
||||
override val type: ContactInfoType = ContactInfoType.Postal
|
||||
|
||||
override val intent: Intent
|
||||
get() = Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse("geo:0,0?q=${URLEncoder.encode(address, "utf8")}"))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
internal data class TelegramContactInfo(
|
||||
override val label: String,
|
||||
val userId: String,
|
||||
) : ContactInfo {
|
||||
|
||||
override val type: ContactInfoType = ContactInfoType.Telegram
|
||||
|
||||
override val intent: Intent
|
||||
get() = Intent(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse("tg:openmessage?user_id=$userId"))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
internal companion object {
|
||||
const val ItemType = "vnd.android.cursor.item/vnd.org.telegram.messenger.android.profile"
|
||||
}
|
||||
}
|
||||
|
||||
internal data class SignalContactInfo(
|
||||
override val label: String,
|
||||
val dataId: Long,
|
||||
) : ContactInfo {
|
||||
|
||||
override val type: ContactInfoType = ContactInfoType.Signal
|
||||
|
||||
override val intent: Intent
|
||||
get() = Intent(Intent.ACTION_VIEW)
|
||||
.setData(
|
||||
Uri.withAppendedPath(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
dataId.toString()
|
||||
)
|
||||
)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
internal companion object {
|
||||
const val ItemType = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"
|
||||
}
|
||||
}
|
||||
|
||||
internal data class WhatsAppContactInfo(
|
||||
override val label: String,
|
||||
val dataId: Long,
|
||||
) : ContactInfo {
|
||||
|
||||
override val type: ContactInfoType = ContactInfoType.Whatsapp
|
||||
|
||||
override val intent: Intent
|
||||
get() = Intent(Intent.ACTION_VIEW)
|
||||
.setData(
|
||||
Uri.withAppendedPath(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
dataId.toString()
|
||||
)
|
||||
)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
internal companion object {
|
||||
const val ItemType = "vnd.android.cursor.item/vnd.com.whatsapp.profile"
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package de.mm20.launcher2.contacts
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.database.getStringOrNull
|
||||
import de.mm20.launcher2.permissions.PermissionGroup
|
||||
import de.mm20.launcher2.permissions.PermissionsManager
|
||||
import de.mm20.launcher2.search.data.Contact
|
||||
import de.mm20.launcher2.search.Contact
|
||||
import de.mm20.launcher2.search.ContactInfo
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -12,14 +15,143 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
interface ContactRepository {
|
||||
fun search(query: String): Flow<ImmutableList<Contact>>
|
||||
}
|
||||
|
||||
internal class ContactRepositoryImpl(
|
||||
internal class ContactRepository(
|
||||
private val context: Context,
|
||||
private val permissionsManager: PermissionsManager
|
||||
) : ContactRepository {
|
||||
) : SearchableRepository<Contact> {
|
||||
|
||||
fun get(id: Long): Flow<Contact?> = flow {
|
||||
val rawContactsCursor = context.contentResolver.query(
|
||||
ContactsContract.RawContacts.CONTENT_URI,
|
||||
arrayOf(ContactsContract.RawContacts._ID),
|
||||
"${ContactsContract.RawContacts.CONTACT_ID} = ?",
|
||||
arrayOf(id.toString()),
|
||||
null
|
||||
)
|
||||
if (rawContactsCursor == null) {
|
||||
emit(null)
|
||||
return@flow
|
||||
}
|
||||
val rawContacts = mutableSetOf<Long>()
|
||||
while (rawContactsCursor.moveToNext()) {
|
||||
rawContacts.add(rawContactsCursor.getLong(0))
|
||||
}
|
||||
rawContactsCursor.close()
|
||||
if (rawContacts.isEmpty()) {
|
||||
emit(null)
|
||||
return@flow
|
||||
}
|
||||
emit(getWithRawIds(id, rawContacts))
|
||||
}
|
||||
|
||||
private suspend fun getWithRawIds(id: Long, rawIds: Set<Long>): Contact? = withContext(Dispatchers.IO) {
|
||||
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} = \"${TelegramContactInfo.ItemType}\"" +
|
||||
" OR ${ContactsContract.Data.MIMETYPE} = \"${WhatsAppContactInfo.ItemType}\"" +
|
||||
" OR ${ContactsContract.Data.MIMETYPE} = \"${SignalContactInfo.ItemType}\"" +
|
||||
")"
|
||||
val dataCursor = context.contentResolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
null, s, null, null
|
||||
) ?: return@withContext null
|
||||
val contactInfos = 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 {
|
||||
contactInfos.add(MailContactInfo(it))
|
||||
}
|
||||
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE ->
|
||||
dataCursor.getStringOrNull(numberColumn)?.let {
|
||||
val phone = it.replace(Regex("[^+0-9]"), "")
|
||||
contactInfos.add(PhoneContactInfo(phone))
|
||||
}
|
||||
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE ->
|
||||
dataCursor.getStringOrNull(addressColumn)?.let {
|
||||
contactInfos.add(PostalContactInfo(it))
|
||||
}
|
||||
|
||||
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
|
||||
firstName = dataCursor.getStringOrNull(givenNameColumn) ?: ""
|
||||
lastName = dataCursor.getStringOrNull(familyNameColumn) ?: ""
|
||||
displayName = dataCursor.getStringOrNull(displayNameColumn) ?: ""
|
||||
}
|
||||
|
||||
TelegramContactInfo.ItemType -> {
|
||||
val data1 = dataCursor.getStringOrNull(data1Column)
|
||||
?: continue@loop
|
||||
val data3 = dataCursor.getStringOrNull(data3Column)
|
||||
?: continue@loop
|
||||
contactInfos.add(
|
||||
TelegramContactInfo(data3.substringAfterLast(" "), data1)
|
||||
)
|
||||
}
|
||||
|
||||
WhatsAppContactInfo.ItemType -> {
|
||||
val data1 = dataCursor.getStringOrNull(data1Column)
|
||||
?: continue@loop
|
||||
val dataId = dataCursor.getLong(idColumn)
|
||||
contactInfos.add(WhatsAppContactInfo("+${data1.substringBefore('@')}", dataId))
|
||||
}
|
||||
|
||||
SignalContactInfo.ItemType -> {
|
||||
val data1 = dataCursor.getStringOrNull(data1Column)
|
||||
?: continue@loop
|
||||
val dataId = dataCursor.getLong(idColumn)
|
||||
contactInfos.add(SignalContactInfo(data1, dataId))
|
||||
}
|
||||
}
|
||||
}
|
||||
dataCursor.close()
|
||||
|
||||
val lookupKeyCursor = context.contentResolver.query(
|
||||
ContactsContract.Contacts.CONTENT_URI,
|
||||
arrayOf(ContactsContract.Contacts.LOOKUP_KEY),
|
||||
"${ContactsContract.Contacts._ID} = ?",
|
||||
arrayOf(id.toString()),
|
||||
null
|
||||
) ?: return@withContext null
|
||||
var lookUpKey = ""
|
||||
if (lookupKeyCursor.moveToNext()) {
|
||||
lookUpKey = lookupKeyCursor.getString(0)
|
||||
}
|
||||
lookupKeyCursor.close()
|
||||
|
||||
return@withContext AndroidContact(
|
||||
id = id,
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
displayName = displayName,
|
||||
contactInfos = contactInfos,
|
||||
lookupKey = lookUpKey
|
||||
)
|
||||
}
|
||||
|
||||
override fun search(query: String): Flow<ImmutableList<Contact>> {
|
||||
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Contacts)
|
||||
@@ -45,7 +177,8 @@ internal class ContactRepositoryImpl(
|
||||
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 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
|
||||
@@ -58,7 +191,7 @@ internal class ContactRepositoryImpl(
|
||||
cursor.close()
|
||||
val results = mutableListOf<Contact>()
|
||||
for ((id, rawIds) in contactMap) {
|
||||
Contact.contactById(context, id, rawIds)?.let { results.add(it) }
|
||||
getWithRawIds(id, rawIds)?.let { results.add(it) }
|
||||
if (results.size > 15) break
|
||||
}
|
||||
results
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
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.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.Contact
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.json.JSONObject
|
||||
|
||||
class ContactSerializer : SearchableSerializer {
|
||||
internal class ContactSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as Contact
|
||||
searchable as AndroidContact
|
||||
return jsonObjectOf(
|
||||
"id" to searchable.id
|
||||
).toString()
|
||||
@@ -24,28 +21,15 @@ class ContactSerializer : SearchableSerializer {
|
||||
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
|
||||
internal class ContactDeserializer(
|
||||
private val contactRepository: ContactRepository,
|
||||
private val permissionsManager: PermissionsManager
|
||||
) : SearchableDeserializer {
|
||||
|
||||
return Contact.contactById(context, id, rawContacts)
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable? {
|
||||
if (!permissionsManager.checkPermissionOnce(PermissionGroup.Contacts)) return null
|
||||
val id = JSONObject(serialized).getLong("id")
|
||||
|
||||
return contactRepository.get(id).first()
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
package de.mm20.launcher2.contacts
|
||||
|
||||
import de.mm20.launcher2.search.Contact
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val contactsModule = module {
|
||||
single<ContactRepository> { ContactRepositoryImpl(androidContext(), get()) }
|
||||
factory { ContactRepository(androidContext(), get()) }
|
||||
factory<SearchableRepository<Contact>>(named<Contact>()) { ContactRepository(androidContext(), get()) }
|
||||
factory<SearchableDeserializer>(named(AndroidContact.Domain)) { ContactDeserializer(get(), get()) }
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
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 signal: 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\"" +
|
||||
" OR ${ContactsContract.Data.MIMETYPE} = \"vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact\"" +
|
||||
")"
|
||||
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 signal = 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()
|
||||
)
|
||||
)
|
||||
}
|
||||
"vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact" -> {
|
||||
val data1 = dataCursor.getStringOrNull(data1Column)
|
||||
?: continue@loop
|
||||
val dataId = dataCursor.getLong(idColumn)
|
||||
signal.add(
|
||||
ContactInfo(
|
||||
data1,
|
||||
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,
|
||||
signal = signal,
|
||||
lookupKey = lookUpKey
|
||||
)
|
||||
}
|
||||
|
||||
const val Domain = "contact"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class ContactInfo(
|
||||
val label: String,
|
||||
val data: String
|
||||
)
|
||||
+2
-3
@@ -1,10 +1,9 @@
|
||||
package de.mm20.launcher2.data.customattrs
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.database.AppDatabase
|
||||
import de.mm20.launcher2.database.entities.CustomAttributeEntity
|
||||
import de.mm20.launcher2.searchable.SearchableRepository
|
||||
import de.mm20.launcher2.searchable.SavableSearchableRepository
|
||||
import de.mm20.launcher2.ktx.jsonObjectOf
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -48,7 +47,7 @@ interface CustomAttributesRepository {
|
||||
|
||||
internal class CustomAttributesRepositoryImpl(
|
||||
private val appDatabase: AppDatabase,
|
||||
private val searchableRepository: SearchableRepository
|
||||
private val searchableRepository: SavableSearchableRepository
|
||||
) : CustomAttributesRepository {
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
|
||||
|
||||
@@ -51,4 +51,5 @@ dependencies {
|
||||
implementation(project(":core:i18n"))
|
||||
implementation(project(":core:permissions"))
|
||||
implementation(project(":core:crashreporter"))
|
||||
implementation(project(":core:preferences"))
|
||||
}
|
||||
@@ -3,18 +3,22 @@ package de.mm20.launcher2.files
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.database.getStringOrNull
|
||||
import de.mm20.launcher2.files.providers.GDriveFile
|
||||
import de.mm20.launcher2.files.providers.LocalFile
|
||||
import de.mm20.launcher2.files.providers.NextcloudFile
|
||||
import de.mm20.launcher2.files.providers.OneDriveFile
|
||||
import de.mm20.launcher2.files.providers.OwncloudFile
|
||||
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 {
|
||||
internal class LocalFileSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as LocalFile
|
||||
return jsonObjectOf(
|
||||
@@ -26,10 +30,10 @@ class LocalFileSerializer : SearchableSerializer {
|
||||
get() = "file"
|
||||
}
|
||||
|
||||
class LocalFileDeserializer(
|
||||
internal class LocalFileDeserializer(
|
||||
val context: Context
|
||||
) : SearchableDeserializer, KoinComponent {
|
||||
override fun deserialize(serialized: String): SavableSearchable? {
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable? {
|
||||
val permissionsManager: PermissionsManager = get()
|
||||
if (!permissionsManager.checkPermissionOnce(
|
||||
PermissionGroup.ExternalStorage
|
||||
@@ -73,7 +77,7 @@ class LocalFileDeserializer(
|
||||
}
|
||||
}
|
||||
|
||||
class GDriveFileSerializer : SearchableSerializer {
|
||||
internal class GDriveFileSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as GDriveFile
|
||||
return jsonObjectOf(
|
||||
@@ -102,8 +106,8 @@ class GDriveFileSerializer : SearchableSerializer {
|
||||
get() = "gdrive"
|
||||
}
|
||||
|
||||
class GDriveFileDeserializer : SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable {
|
||||
internal class GDriveFileDeserializer : SearchableDeserializer {
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable {
|
||||
val json = JSONObject(serialized)
|
||||
val id = json.getString("id")
|
||||
val label = json.getString("label")
|
||||
@@ -133,7 +137,7 @@ class GDriveFileDeserializer : SearchableDeserializer {
|
||||
}
|
||||
}
|
||||
|
||||
class OneDriveFileSerializer : SearchableSerializer {
|
||||
internal class OneDriveFileSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as OneDriveFile
|
||||
return jsonObjectOf(
|
||||
@@ -160,8 +164,8 @@ class OneDriveFileSerializer : SearchableSerializer {
|
||||
get() = "onedrive"
|
||||
}
|
||||
|
||||
class OneDriveFileDeserializer : SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable {
|
||||
internal class OneDriveFileDeserializer : SearchableDeserializer {
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable {
|
||||
val json = JSONObject(serialized)
|
||||
val fileId = json.getString("id")
|
||||
val label = json.getString("label")
|
||||
@@ -188,7 +192,7 @@ class OneDriveFileDeserializer : SearchableDeserializer {
|
||||
}
|
||||
}
|
||||
|
||||
class NextcloudFileSerializer : SearchableSerializer {
|
||||
internal class NextcloudFileSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as NextcloudFile
|
||||
return jsonObjectOf(
|
||||
@@ -215,8 +219,8 @@ class NextcloudFileSerializer : SearchableSerializer {
|
||||
get() = "nextcloud"
|
||||
}
|
||||
|
||||
class NextcloudFileDeserializer : SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable {
|
||||
internal class NextcloudFileDeserializer : SearchableDeserializer {
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable {
|
||||
val json = JSONObject(serialized)
|
||||
val id = json.getLong("id")
|
||||
val label = json.getString("label")
|
||||
@@ -241,7 +245,7 @@ class NextcloudFileDeserializer : SearchableDeserializer {
|
||||
}
|
||||
}
|
||||
|
||||
class OwncloudFileSerializer : SearchableSerializer {
|
||||
internal class OwncloudFileSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as OwncloudFile
|
||||
return jsonObjectOf(
|
||||
@@ -268,8 +272,8 @@ class OwncloudFileSerializer : SearchableSerializer {
|
||||
get() = "owncloud"
|
||||
}
|
||||
|
||||
class OwncloudFileDeserializer : SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable {
|
||||
internal class OwncloudFileDeserializer : SearchableDeserializer {
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable {
|
||||
val json = JSONObject(serialized)
|
||||
val id = json.getLong("id")
|
||||
val label = json.getString("label")
|
||||
|
||||
@@ -9,34 +9,23 @@ 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 de.mm20.launcher2.preferences.LauncherDataStore
|
||||
import de.mm20.launcher2.search.File
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
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
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
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(
|
||||
internal class FileRepository(
|
||||
private val context: Context,
|
||||
private val permissionsManager: PermissionsManager,
|
||||
) : FileRepository {
|
||||
private val dataStore: LauncherDataStore,
|
||||
) : SearchableRepository<File> {
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
|
||||
@@ -49,39 +38,28 @@ internal class FileRepositoryImpl(
|
||||
|
||||
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>()
|
||||
dataStore.data.map { it.fileSearch }.collectLatest {
|
||||
val providers = mutableListOf<FileProvider>()
|
||||
|
||||
if (local) providers.add(LocalFileProvider(context, permissionsManager))
|
||||
if (gdrive) providers.add(GDriveFileProvider(context))
|
||||
if (nextcloud) providers.add(NextcloudFileProvider(nextcloudClient))
|
||||
if (owncloud) providers.add(OwncloudFileProvider(owncloudClient))
|
||||
if (it.localFiles) providers.add(LocalFileProvider(context, permissionsManager))
|
||||
if (it.gdrive) providers.add(GDriveFileProvider(context))
|
||||
if (it.nextcloud) providers.add(NextcloudFileProvider(nextcloudClient))
|
||||
if (it.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)
|
||||
if (providers.isEmpty()) {
|
||||
send(persistentListOf())
|
||||
return@collectLatest
|
||||
}
|
||||
val results = mutableListOf<File>()
|
||||
for (provider in providers) {
|
||||
results.addAll(provider.search(query))
|
||||
send(results.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
package de.mm20.launcher2.files
|
||||
|
||||
import de.mm20.launcher2.files.providers.GDriveFile
|
||||
import de.mm20.launcher2.files.providers.LocalFile
|
||||
import de.mm20.launcher2.files.providers.NextcloudFile
|
||||
import de.mm20.launcher2.files.providers.OneDriveFile
|
||||
import de.mm20.launcher2.files.providers.OwncloudFile
|
||||
import de.mm20.launcher2.search.File
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val filesModule = module {
|
||||
single<FileRepository> { FileRepositoryImpl(androidContext(), get()) }
|
||||
factory<SearchableRepository<File>>(named<File>()) { FileRepository(androidContext(), get(), get()) }
|
||||
factory<SearchableDeserializer>(named(LocalFile.Domain)) { LocalFileDeserializer(androidContext()) }
|
||||
factory<SearchableDeserializer>(named(OwncloudFile.Domain)) { OwncloudFileDeserializer() }
|
||||
factory<SearchableDeserializer>(named(NextcloudFile.Domain)) { NextcloudFileDeserializer() }
|
||||
factory<SearchableDeserializer>(named(OneDriveFile.Domain)) { OneDriveFileDeserializer() }
|
||||
factory<SearchableDeserializer>(named(GDriveFile.Domain)) { GDriveFileDeserializer() }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
import de.mm20.launcher2.search.data.File
|
||||
import de.mm20.launcher2.search.File
|
||||
|
||||
interface FileProvider {
|
||||
internal interface FileProvider {
|
||||
suspend fun search(query: String): List<File>
|
||||
}
|
||||
+9
-2
@@ -1,13 +1,16 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import de.mm20.launcher2.files.GDriveFileSerializer
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.search.File
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
|
||||
data class GDriveFile(
|
||||
internal data class GDriveFile(
|
||||
val fileId: String,
|
||||
override val label: String,
|
||||
override val path: String,
|
||||
@@ -43,6 +46,10 @@ data class GDriveFile(
|
||||
return context.tryStartActivity(getLaunchIntent(), options)
|
||||
}
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return GDriveFileSerializer()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "gdrive"
|
||||
}
|
||||
@@ -4,8 +4,7 @@ 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
|
||||
import de.mm20.launcher2.search.File
|
||||
|
||||
internal class GDriveFileProvider(
|
||||
private val context: Context
|
||||
|
||||
+11
-3
@@ -1,4 +1,4 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -16,17 +16,21 @@ import android.util.Size
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.files.LocalFileSerializer
|
||||
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 de.mm20.launcher2.search.File
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.io.File as JavaIOFile
|
||||
|
||||
data class LocalFile(
|
||||
internal data class LocalFile(
|
||||
val id: Long,
|
||||
override val path: String,
|
||||
override val mimeType: String,
|
||||
@@ -186,7 +190,7 @@ data class LocalFile(
|
||||
|
||||
val file = java.io.File(path)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(NonCancellable + Dispatchers.IO) {
|
||||
file.deleteRecursively()
|
||||
|
||||
context.contentResolver.delete(
|
||||
@@ -352,4 +356,8 @@ data class LocalFile(
|
||||
shareIntent.type = mimeType
|
||||
context.startActivity(Intent.createChooser(shareIntent, null))
|
||||
}
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return LocalFileSerializer()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
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 de.mm20.launcher2.search.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
+9
-2
@@ -1,14 +1,17 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
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.NextcloudFileSerializer
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.search.File
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
|
||||
data class NextcloudFile(
|
||||
internal data class NextcloudFile(
|
||||
val fileId: Long,
|
||||
override val label: String,
|
||||
override val path: String,
|
||||
@@ -45,6 +48,10 @@ data class NextcloudFile(
|
||||
return context.tryStartActivity(getLaunchIntent(context), options)
|
||||
}
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return NextcloudFileSerializer()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val Domain = "nextcloud"
|
||||
@@ -2,8 +2,7 @@ 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 de.mm20.launcher2.search.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.min
|
||||
|
||||
+9
-2
@@ -1,13 +1,16 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import de.mm20.launcher2.files.OneDriveFileSerializer
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.search.File
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
|
||||
data class OneDriveFile(
|
||||
internal data class OneDriveFile(
|
||||
val fileId: String,
|
||||
override val label: String,
|
||||
override val path: String,
|
||||
@@ -42,6 +45,10 @@ data class OneDriveFile(
|
||||
return context.tryStartActivity(getLaunchIntent(), options)
|
||||
}
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return OneDriveFileSerializer()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "onedrive"
|
||||
}
|
||||
+9
-2
@@ -1,13 +1,16 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import de.mm20.launcher2.files.OwncloudFileSerializer
|
||||
import de.mm20.launcher2.files.R
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.search.File
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
|
||||
data class OwncloudFile(
|
||||
internal data class OwncloudFile(
|
||||
val fileId: Long,
|
||||
override val label: String,
|
||||
override val path: String,
|
||||
@@ -42,6 +45,10 @@ data class OwncloudFile(
|
||||
return context.tryStartActivity(getLaunchIntent(), options)
|
||||
}
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return OwncloudFileSerializer()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "owncloud"
|
||||
}
|
||||
@@ -2,8 +2,7 @@ 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
|
||||
import de.mm20.launcher2.search.File
|
||||
|
||||
internal class OwncloudFileProvider(
|
||||
private val owncloudClient: OwncloudClient
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
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
|
||||
"application/vnd.de.mm20.launcher2.backup" -> R.drawable.ic_file_backup to R.color.brown
|
||||
"application/vnd.de.mm20.launcher2.theme" -> R.drawable.ic_file_theme to R.color.amber
|
||||
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/vnd.de.mm20.launcher2.backup") {
|
||||
return context.getString(
|
||||
R.string.file_type_launcherbackup,
|
||||
context.getString(R.string.app_name)
|
||||
)
|
||||
}
|
||||
if (mimeType == "application/vnd.de.mm20.launcher2.theme") {
|
||||
return context.getString(
|
||||
R.string.file_type_launchertheme,
|
||||
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())
|
||||
return context.getString(R.string.file_type_generic, extension)
|
||||
}
|
||||
return context.getString(resource)
|
||||
}
|
||||
|
||||
val isDeletable: Boolean
|
||||
get() = false
|
||||
|
||||
val canShare: Boolean
|
||||
get() = false
|
||||
|
||||
fun share(context: Context) {}
|
||||
suspend fun delete(context: Context) {}
|
||||
|
||||
}
|
||||
@@ -45,12 +45,7 @@ dependencies {
|
||||
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"))
|
||||
|
||||
@@ -6,6 +6,8 @@ import de.mm20.launcher2.icons.ColorLayer
|
||||
import de.mm20.launcher2.icons.StaticLauncherIcon
|
||||
import de.mm20.launcher2.icons.TextLayer
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import de.mm20.launcher2.searchable.TagSerializer
|
||||
|
||||
data class Tag(
|
||||
val tag: String,
|
||||
@@ -33,6 +35,10 @@ data class Tag(
|
||||
)
|
||||
}
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return TagSerializer()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "tag"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package de.mm20.launcher2.searchable
|
||||
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.data.Tag
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val searchableModule = module {
|
||||
single<SearchableRepository> { SearchableRepositoryImpl(androidContext(), get(), get()) }
|
||||
factory <SavableSearchableRepository> { SavableSearchableRepositoryImpl(androidContext(), get(), get()) }
|
||||
factory<SearchableDeserializer>(named(Tag.Domain)) { TagDeserializer() }
|
||||
}
|
||||
+18
-7
@@ -24,9 +24,13 @@ import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.error.InstanceCreationException
|
||||
import org.koin.core.error.NoBeanDefFoundException
|
||||
import org.koin.core.qualifier.named
|
||||
import java.io.File
|
||||
|
||||
interface SearchableRepository {
|
||||
interface SavableSearchableRepository {
|
||||
|
||||
fun insert(
|
||||
searchable: SavableSearchable,
|
||||
@@ -114,11 +118,11 @@ interface SearchableRepository {
|
||||
suspend fun cleanupDatabase(): Int
|
||||
}
|
||||
|
||||
internal class SearchableRepositoryImpl(
|
||||
internal class SavableSearchableRepositoryImpl(
|
||||
private val context: Context,
|
||||
private val database: AppDatabase,
|
||||
private val dataStore: LauncherDataStore
|
||||
) : SearchableRepository, KoinComponent {
|
||||
) : SavableSearchableRepository, KoinComponent {
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
|
||||
@@ -348,10 +352,17 @@ internal class SearchableRepositoryImpl(
|
||||
return database.searchableDao().sortByWeight(keys)
|
||||
}
|
||||
|
||||
private fun fromDatabaseEntity(entity: SavedSearchableEntity): SavedSearchable {
|
||||
val deserializer: SearchableDeserializer =
|
||||
getDeserializer(context, entity.type)
|
||||
val searchable = deserializer.deserialize(entity.serializedSearchable)
|
||||
private suspend fun fromDatabaseEntity(entity: SavedSearchableEntity): SavedSearchable {
|
||||
val deserializer: SearchableDeserializer? = try {
|
||||
get(named(entity.type))
|
||||
} catch (e: NoBeanDefFoundException) {
|
||||
CrashReporter.logException(e)
|
||||
null
|
||||
} catch (e: InstanceCreationException) {
|
||||
CrashReporter.logException(e)
|
||||
null
|
||||
}
|
||||
val searchable = deserializer?.deserialize(entity.serializedSearchable)
|
||||
if (searchable == null) removeInvalidItem(entity.key)
|
||||
return SavedSearchable(
|
||||
key = entity.key,
|
||||
@@ -15,9 +15,7 @@ data class SavedSearchable(
|
||||
var weight: Double
|
||||
) {
|
||||
fun toDatabaseEntity(): SavedSearchableEntity? {
|
||||
val serializer = getSerializer(searchable)
|
||||
|
||||
val data = searchable?.let { serializer.serialize(it) } ?: return null
|
||||
val data = searchable?.serialize() ?: return null
|
||||
|
||||
return SavedSearchableEntity(
|
||||
key = key,
|
||||
|
||||
@@ -1,114 +1,8 @@
|
||||
package de.mm20.launcher2.searchable
|
||||
|
||||
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.SavableSearchable
|
||||
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 SavableSearchable.serialize(): String? {
|
||||
val serializer = getSerializer(this)
|
||||
val serializer = getSerializer()
|
||||
return serializer.serialize(this)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class TagSerializer: SearchableSerializer {
|
||||
}
|
||||
|
||||
class TagDeserializer: SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable {
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable {
|
||||
val json = JSONObject(serialized)
|
||||
|
||||
return Tag(json.getString("tag"))
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package de.mm20.launcher2.websites
|
||||
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import de.mm20.launcher2.search.Website
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val websitesModule = module {
|
||||
single<WebsiteRepository> { WebsiteRepositoryImpl(androidContext()) }
|
||||
single<SearchableRepository<Website>>(named<Website>()) { WebsiteRepository(androidContext()) }
|
||||
factory<SearchableDeserializer>(named(WebsiteImpl.Domain)) { WebsiteDeserializer() }
|
||||
}
|
||||
+19
-13
@@ -1,4 +1,4 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.websites
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -9,19 +9,19 @@ import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import de.mm20.launcher2.icons.*
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.websites.R
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import de.mm20.launcher2.search.Website
|
||||
import java.util.concurrent.ExecutionException
|
||||
|
||||
data class Website(
|
||||
internal data class WebsiteImpl(
|
||||
override val label: String,
|
||||
val url: String,
|
||||
val description: String,
|
||||
val image: String,
|
||||
val favicon: String,
|
||||
val color: Int,
|
||||
override val url: String,
|
||||
override val description: String,
|
||||
override val imageUrl: String,
|
||||
override val faviconUrl: String,
|
||||
override val color: Int,
|
||||
override val labelOverride: String? = null,
|
||||
) : SavableSearchable {
|
||||
) : Website {
|
||||
|
||||
override val domain: String = Domain
|
||||
|
||||
@@ -38,10 +38,10 @@ data class Website(
|
||||
size: Int,
|
||||
themed: Boolean,
|
||||
): LauncherIcon? {
|
||||
if (favicon.isEmpty()) return null
|
||||
if (faviconUrl.isEmpty()) return null
|
||||
try {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(favicon)
|
||||
.data(faviconUrl)
|
||||
.size(size)
|
||||
.allowHardware(false)
|
||||
.build()
|
||||
@@ -90,7 +90,9 @@ data class Website(
|
||||
return context.tryStartActivity(getLaunchIntent(), options)
|
||||
}
|
||||
|
||||
fun share(context: Context) {
|
||||
override val canShare: Boolean = true
|
||||
|
||||
override fun share(context: Context) {
|
||||
val shareIntent = Intent(Intent.ACTION_SEND)
|
||||
shareIntent.putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
@@ -100,6 +102,10 @@ data class Website(
|
||||
context.startActivity(Intent.createChooser(shareIntent, null))
|
||||
}
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return WebsiteSerializer()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "web"
|
||||
}
|
||||
@@ -2,8 +2,12 @@ package de.mm20.launcher2.websites
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.URLUtil
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.core.graphics.toColorInt
|
||||
import de.mm20.launcher2.search.data.Website
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import de.mm20.launcher2.search.Website
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
@@ -20,11 +24,8 @@ import java.net.URISyntaxException
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface WebsiteRepository {
|
||||
fun search(query: String): Flow<Website?>
|
||||
}
|
||||
|
||||
internal class WebsiteRepositoryImpl(val context: Context) : WebsiteRepository, KoinComponent {
|
||||
internal class WebsiteRepository(val context: Context) : SearchableRepository<Website> {
|
||||
|
||||
private val httpClient = OkHttpClient
|
||||
.Builder()
|
||||
@@ -33,16 +34,17 @@ internal class WebsiteRepositoryImpl(val context: Context) : WebsiteRepository,
|
||||
.writeTimeout(1000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
override fun search(query: String): Flow<Website?> = channelFlow {
|
||||
send(null)
|
||||
override fun search(query: String): Flow<ImmutableList<Website>> = channelFlow {
|
||||
send(persistentListOf())
|
||||
withContext(Dispatchers.IO) {
|
||||
httpClient.dispatcher.cancelAll()
|
||||
}
|
||||
if (query.isBlank()) return@channelFlow
|
||||
|
||||
val website = queryWebsite(query)
|
||||
send(website)
|
||||
|
||||
website?.let {
|
||||
send(persistentListOf(it))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryWebsite(query: String): Website? {
|
||||
@@ -84,12 +86,12 @@ internal class WebsiteRepositoryImpl(val context: Context) : WebsiteRepository,
|
||||
doc.head().select("link[href~=.*\\.(ico|png)]").attr("href")
|
||||
if (favicon.isNotBlank()) favicon = resolveUrl(response.request.url, favicon)
|
||||
if (image.isNotBlank()) image = resolveUrl(response.request.url, image)
|
||||
return@withContext Website(
|
||||
return@withContext WebsiteImpl(
|
||||
label = title,
|
||||
url = url,
|
||||
description = description,
|
||||
image = image,
|
||||
favicon = favicon,
|
||||
imageUrl = image,
|
||||
faviconUrl = favicon,
|
||||
color = color
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
|
||||
@@ -4,18 +4,17 @@ 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.Website
|
||||
import org.json.JSONObject
|
||||
|
||||
class WebsiteSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String {
|
||||
searchable as Website
|
||||
searchable as WebsiteImpl
|
||||
return jsonObjectOf(
|
||||
"label" to searchable.label,
|
||||
"url" to searchable.url,
|
||||
"description" to searchable.description,
|
||||
"image" to searchable.image,
|
||||
"favicon" to searchable.favicon,
|
||||
"image" to searchable.imageUrl,
|
||||
"favicon" to searchable.faviconUrl,
|
||||
"color" to searchable.color
|
||||
).toString()
|
||||
}
|
||||
@@ -25,12 +24,12 @@ class WebsiteSerializer : SearchableSerializer {
|
||||
}
|
||||
|
||||
class WebsiteDeserializer: SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable? {
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable? {
|
||||
val json = JSONObject(serialized)
|
||||
return Website(
|
||||
return WebsiteImpl(
|
||||
label = json.getString("label"),
|
||||
favicon = json.getString("favicon"),
|
||||
image = json.getString("image"),
|
||||
faviconUrl = json.getString("favicon"),
|
||||
imageUrl = json.getString("image"),
|
||||
description = json.getString("description"),
|
||||
url = json.getString("url"),
|
||||
color = json.getInt("color")
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package de.mm20.launcher2.wikipedia
|
||||
|
||||
import de.mm20.launcher2.search.Article
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val wikipediaModule = module {
|
||||
single<WikipediaRepository> { WikipediaRepositoryImpl(androidContext(), get()) }
|
||||
single<SearchableRepository<Article>>(named<Article>()) { WikipediaRepository(androidContext(), get()) }
|
||||
factory<SearchableDeserializer>(named(Wikipedia.Domain)) { WikipediaDeserializer(androidContext()) }
|
||||
}
|
||||
+17
-15
@@ -1,34 +1,31 @@
|
||||
package de.mm20.launcher2.search.data
|
||||
package de.mm20.launcher2.wikipedia
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.HtmlCompat
|
||||
import de.mm20.launcher2.icons.ColorLayer
|
||||
import de.mm20.launcher2.icons.StaticLauncherIcon
|
||||
import de.mm20.launcher2.icons.TintedIconLayer
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.wikipedia.R
|
||||
import de.mm20.launcher2.search.Article
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
|
||||
data class Wikipedia(
|
||||
internal data class Wikipedia(
|
||||
override val label: String,
|
||||
val id: Long,
|
||||
val text: String,
|
||||
val image: String?,
|
||||
val url: String,
|
||||
override val text: String,
|
||||
override val imageUrl: String?,
|
||||
override val sourceUrl: String,
|
||||
override val sourceName: String,
|
||||
val wikipediaUrl: String,
|
||||
override val labelOverride: String? = null,
|
||||
) : SavableSearchable {
|
||||
) : Article {
|
||||
|
||||
override val domain: String = Domain
|
||||
|
||||
override val preferDetailsOverLaunch: Boolean = false
|
||||
|
||||
override fun overrideLabel(label: String): Wikipedia {
|
||||
return this.copy(labelOverride = label)
|
||||
}
|
||||
@@ -47,25 +44,30 @@ data class Wikipedia(
|
||||
}
|
||||
|
||||
private fun getLaunchIntent(): Intent {
|
||||
return Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
return Intent(Intent.ACTION_VIEW, Uri.parse(sourceUrl))
|
||||
}
|
||||
|
||||
override fun launch(context: Context, options: Bundle?): Boolean {
|
||||
return context.tryStartActivity(getLaunchIntent(), options)
|
||||
}
|
||||
|
||||
fun share(context: Context) {
|
||||
override val canShare: Boolean = true
|
||||
override fun share(context: Context) {
|
||||
val text = HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY).toString()
|
||||
val shareIntent = Intent(Intent.ACTION_SEND)
|
||||
shareIntent.putExtra(
|
||||
Intent.EXTRA_TEXT, "${label}\n\n" +
|
||||
"${text.substring(0, 200)}…\n\n" +
|
||||
url
|
||||
sourceUrl
|
||||
)
|
||||
shareIntent.type = "text/plain"
|
||||
context.startActivity(Intent.createChooser(shareIntent, null))
|
||||
}
|
||||
|
||||
override fun getSerializer(): SearchableSerializer {
|
||||
return WikipediaSerializer()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val Domain = "wikipedia"
|
||||
}
|
||||
@@ -3,7 +3,10 @@ package de.mm20.launcher2.wikipedia
|
||||
import android.content.Context
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.preferences.LauncherDataStore
|
||||
import de.mm20.launcher2.search.data.Wikipedia
|
||||
import de.mm20.launcher2.search.Article
|
||||
import de.mm20.launcher2.search.SearchableRepository
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -12,14 +15,11 @@ import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface WikipediaRepository {
|
||||
fun search(query: String, loadImages: Boolean = false): Flow<Wikipedia?>
|
||||
}
|
||||
|
||||
internal class WikipediaRepositoryImpl(
|
||||
internal class WikipediaRepository(
|
||||
private val context: Context,
|
||||
private val dataStore: LauncherDataStore
|
||||
) : WikipediaRepository, KoinComponent {
|
||||
) : SearchableRepository<Article> {
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
|
||||
@@ -55,8 +55,8 @@ internal class WikipediaRepositoryImpl(
|
||||
private lateinit var wikipediaService: WikipediaApi
|
||||
|
||||
|
||||
override fun search(query: String, loadImages: Boolean): Flow<Wikipedia?> = channelFlow {
|
||||
send(null)
|
||||
override fun search(query: String): Flow<ImmutableList<Wikipedia>> = channelFlow {
|
||||
send(persistentListOf())
|
||||
withContext(Dispatchers.IO) {
|
||||
httpClient.dispatcher.cancelAll()
|
||||
}
|
||||
@@ -66,7 +66,10 @@ internal class WikipediaRepositoryImpl(
|
||||
if (!::wikipediaService.isInitialized) return@channelFlow
|
||||
if (query.isBlank()) return@channelFlow
|
||||
|
||||
send(queryWikipedia(query, loadImages))
|
||||
dataStore.data.map { it.wikipediaSearch.images }.collectLatest {
|
||||
val wikipedia = queryWikipedia(query, false)
|
||||
send(wikipedia?.let { persistentListOf(it) } ?: persistentListOf())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryWikipedia(query: String, loadImages: Boolean): Wikipedia? {
|
||||
@@ -92,9 +95,10 @@ internal class WikipediaRepositoryImpl(
|
||||
label = page.title,
|
||||
id = page.pageid,
|
||||
text = page.extract,
|
||||
image = image,
|
||||
url = page.fullurl,
|
||||
wikipediaUrl = wikipediaUrl
|
||||
imageUrl = image,
|
||||
sourceUrl = page.fullurl,
|
||||
wikipediaUrl = wikipediaUrl,
|
||||
sourceName = context.getString(R.string.wikipedia_source),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.SearchableDeserializer
|
||||
import de.mm20.launcher2.search.SearchableSerializer
|
||||
import de.mm20.launcher2.search.data.Wikipedia
|
||||
import org.json.JSONObject
|
||||
|
||||
class WikipediaSerializer : SearchableSerializer {
|
||||
@@ -14,9 +13,9 @@ class WikipediaSerializer : SearchableSerializer {
|
||||
json.put("label", searchable.label)
|
||||
json.put("text", searchable.text)
|
||||
json.put("id", searchable.id)
|
||||
json.put("image", searchable.image)
|
||||
json.put("image", searchable.imageUrl)
|
||||
json.put("wikipedia_url", searchable.wikipediaUrl)
|
||||
json.put("url", searchable.url)
|
||||
json.put("url", searchable.sourceUrl)
|
||||
return json.toString()
|
||||
}
|
||||
|
||||
@@ -25,7 +24,7 @@ class WikipediaSerializer : SearchableSerializer {
|
||||
}
|
||||
|
||||
class WikipediaDeserializer(val context: Context) : SearchableDeserializer {
|
||||
override fun deserialize(serialized: String): SavableSearchable? {
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable? {
|
||||
val json = JSONObject(serialized)
|
||||
val wikipediaUrl = json.optString("wikipedia_url").takeIf { !it.isNullOrBlank() } ?: return null
|
||||
val id = json.getLong("id")
|
||||
@@ -33,9 +32,10 @@ class WikipediaDeserializer(val context: Context) : SearchableDeserializer {
|
||||
label = json.getString("label"),
|
||||
text = json.getString("text"),
|
||||
id = id,
|
||||
image = json.optString("image"),
|
||||
url = json.optString("url").takeIf { !it.isNullOrBlank() } ?: "${wikipediaUrl.padEnd(1, '/')}wiki?curid=$id",
|
||||
wikipediaUrl = wikipediaUrl
|
||||
imageUrl = json.optString("image"),
|
||||
sourceUrl = json.optString("url").takeIf { !it.isNullOrBlank() } ?: "${wikipediaUrl.padEnd(1, '/')}wiki?curid=$id",
|
||||
wikipediaUrl = wikipediaUrl,
|
||||
sourceName = context.getString(R.string.wikipedia_source),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user