Annual refactor

This commit is contained in:
MM20
2023-10-28 14:20:16 +02:00
parent 4f64652aae
commit 957358c79a
110 changed files with 1786 additions and 1663 deletions
@@ -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("")
}
}
}
@@ -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()
@@ -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>()!!
@@ -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)
}
@@ -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())
}
}
@@ -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)
}
)
}
@@ -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)
@@ -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 =
@@ -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()) }
}
@@ -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)
}
}
}
@@ -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
)
@@ -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)
+1
View File
@@ -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>
}
@@ -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
@@ -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
@@ -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
@@ -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"
}
@@ -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) {}
}
-5
View File
@@ -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() }
}
@@ -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() }
}
@@ -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()) }
}
@@ -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),
)
}
}