Annual refactor

This commit is contained in:
MM20 2023-10-28 14:14:08 +02:00
parent 4f64652aae
commit 957358c79a
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
110 changed files with 1786 additions and 1663 deletions

View File

@ -3,8 +3,7 @@ package de.mm20.launcher2.activity
import android.app.Activity
import android.os.Bundle
import android.util.Log
import de.mm20.launcher2.searchable.SearchableRepository
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.appshortcuts.AppShortcut
import de.mm20.launcher2.services.favorites.FavoritesService
import org.koin.android.ext.android.inject
@ -14,7 +13,7 @@ class AddItemActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val shortcut = AppShortcut.fromPinRequestIntent(this, intent)
val shortcut = AppShortcut(this, intent)
if (shortcut != null) {
favoritesService.pinItem(shortcut)
} else {

View File

@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.data.customattrs.utils.withCustomLabels
import de.mm20.launcher2.searchable.SearchableRepository
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.Tag

View File

@ -31,8 +31,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import coil.compose.AsyncImage
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.LauncherApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.abs
@ -107,23 +107,12 @@ fun rememberSplashScreenData(searchable: SavableSearchable?): SplashScreenData {
LaunchedEffect(searchable) {
withContext(Dispatchers.IO) {
if (searchable is LauncherApp) {
val activityInfo = if (isAtLeastApiLevel(31)) {
searchable.launcherActivityInfo.activityInfo
} else {
try {
context.packageManager.getActivityInfo(
searchable.launcherActivityInfo.componentName,
0
)
} catch (e: PackageManager.NameNotFoundException) {
null
}
} ?: return@withContext
if (searchable is Application) {
val activityInfo = searchable.getActivityInfo(context) ?: return@withContext
val themeRes = activityInfo.themeResource
val ctx = try {
context.createPackageContext(
searchable.`package`,
searchable.componentName.packageName,
Context.CONTEXT_IGNORE_SECURITY
)
} catch (e: PackageManager.NameNotFoundException) {

View File

@ -8,7 +8,7 @@ import androidx.compose.runtime.setValue
import androidx.core.app.ActivityOptionsCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.searchable.SearchableRepository
import de.mm20.launcher2.searchable.SavableSearchableRepository
import de.mm20.launcher2.globalactions.GlobalActionsService
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
@ -35,7 +35,7 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent {
private val dataStore: LauncherDataStore by inject()
private val globalActionsService: GlobalActionsService by inject()
private val permissionsManager: PermissionsManager by inject()
private val searchableRepository: SearchableRepository by inject()
private val searchableRepository: SavableSearchableRepository by inject()
private var isSystemInDarkMode = MutableStateFlow(false)

View File

@ -1,10 +1,7 @@
package de.mm20.launcher2.ui.launcher.search
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -18,29 +15,22 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.Tag
import androidx.compose.material.icons.rounded.Work
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.platform.LocalContext
@ -61,7 +51,7 @@ import de.mm20.launcher2.ui.launcher.search.common.list.ListItem
import de.mm20.launcher2.ui.launcher.search.favorites.SearchFavoritesVM
import de.mm20.launcher2.ui.launcher.search.unitconverter.UnitConverterItem
import de.mm20.launcher2.ui.launcher.search.website.WebsiteItem
import de.mm20.launcher2.ui.launcher.search.wikipedia.WikipediaItem
import de.mm20.launcher2.ui.launcher.search.wikipedia.ArticleItem
import de.mm20.launcher2.ui.launcher.sheets.HiddenItemsSheet
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalCardStyle
@ -99,7 +89,7 @@ fun SearchColumn(
val events by viewModel.calendarResults
val unitConverter by viewModel.unitConverterResults
val calculator by viewModel.calculatorResults
val wikipedia by viewModel.wikipediaResults
val wikipedia by viewModel.articleResults
val website by viewModel.websiteResults
val hiddenResults by viewModel.hiddenResults
@ -307,7 +297,7 @@ fun SearchColumn(
)
for (wiki in wikipedia) {
SingleResult(highlight = bestMatch == wiki) {
WikipediaItem(wikipedia = wiki)
ArticleItem(article = wiki)
}
}
for (ws in website) {

View File

@ -5,23 +5,24 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.searchable.SearchableRepository
import de.mm20.launcher2.searchable.SavableSearchableRepository
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings.SearchResultOrderingSettings.Ordering
import de.mm20.launcher2.search.AppProfile
import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchService
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.search.Article
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.Website
import de.mm20.launcher2.search.data.Calculator
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.search.data.Contact
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.data.UnitConverter
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.search.data.Wikipedia
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.services.favorites.FavoritesService
import kotlinx.coroutines.CancellationException
@ -43,7 +44,7 @@ import org.koin.core.component.inject
class SearchVM : ViewModel(), KoinComponent {
private val favoritesService: FavoritesService by inject()
private val searchableRepository: SearchableRepository by inject()
private val searchableRepository: SavableSearchableRepository by inject()
private val permissionsManager: PermissionsManager by inject()
private val dataStore: LauncherDataStore by inject()
@ -55,13 +56,13 @@ class SearchVM : ViewModel(), KoinComponent {
val searchQuery = mutableStateOf("")
val isSearchEmpty = mutableStateOf(true)
val appResults = mutableStateOf<List<LauncherApp>>(emptyList())
val workAppResults = mutableStateOf<List<LauncherApp>>(emptyList())
val appResults = mutableStateOf<List<Application>>(emptyList())
val workAppResults = mutableStateOf<List<Application>>(emptyList())
val appShortcutResults = mutableStateOf<List<AppShortcut>>(emptyList())
val fileResults = mutableStateOf<List<File>>(emptyList())
val contactResults = mutableStateOf<List<Contact>>(emptyList())
val calendarResults = mutableStateOf<List<CalendarEvent>>(emptyList())
val wikipediaResults = mutableStateOf<List<Wikipedia>>(emptyList())
val articleResults = mutableStateOf<List<Article>>(emptyList())
val websiteResults = mutableStateOf<List<Website>>(emptyList())
val calculatorResults = mutableStateOf<List<Calculator>>(emptyList())
val unitConverterResults = mutableStateOf<List<UnitConverter>>(emptyList())
@ -185,15 +186,15 @@ class SearchVM : ViewModel(), KoinComponent {
hiddenItemKeys.collectLatest { hiddenKeys ->
val hidden = mutableListOf<SavableSearchable>()
val apps = mutableListOf<LauncherApp>()
val workApps = mutableListOf<LauncherApp>()
val apps = mutableListOf<Application>()
val workApps = mutableListOf<Application>()
val shortcuts = mutableListOf<AppShortcut>()
val files = mutableListOf<File>()
val contacts = mutableListOf<Contact>()
val events = mutableListOf<CalendarEvent>()
val unitConv = mutableListOf<UnitConverter>()
val calc = mutableListOf<Calculator>()
val wikipedia = mutableListOf<Wikipedia>()
val articles = mutableListOf<Article>()
val website = mutableListOf<Website>()
val actions = mutableListOf<SearchAction>()
for (r in resultsList) {
@ -202,8 +203,8 @@ class SearchVM : ViewModel(), KoinComponent {
hidden.add(r)
}
r is LauncherApp && !r.isMainProfile -> workApps.add(r)
r is LauncherApp -> apps.add(r)
r is Application && r.profile == AppProfile.Work -> workApps.add(r)
r is Application -> apps.add(r)
r is AppShortcut -> shortcuts.add(r)
r is File -> files.add(r)
r is Contact -> contacts.add(r)
@ -211,7 +212,7 @@ class SearchVM : ViewModel(), KoinComponent {
r is UnitConverter -> unitConv.add(r)
r is Calculator -> calc.add(r)
r is Website -> website.add(r)
r is Wikipedia -> wikipedia.add(r)
r is Article -> articles.add(r)
r is SearchAction -> actions.add(r)
}
}
@ -225,7 +226,7 @@ class SearchVM : ViewModel(), KoinComponent {
calc,
events,
contacts,
wikipedia,
articles,
website,
files,
actions
@ -238,7 +239,7 @@ class SearchVM : ViewModel(), KoinComponent {
fileResults.value = files
contactResults.value = contacts
calendarResults.value = events
wikipediaResults.value = wikipedia
articleResults.value = articles
websiteResults.value = website
calculatorResults.value = calc
unitConverterResults.value = unitConv

View File

@ -66,7 +66,7 @@ import androidx.lifecycle.lifecycleScope
import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
@ -82,12 +82,11 @@ import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.launch
import kotlin.math.pow
@Composable
fun AppItem(
modifier: Modifier = Modifier,
app: LauncherApp,
app: Application,
onBack: () -> Unit
) {
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${app.key}")
@ -127,7 +126,7 @@ fun AppItem(
}
app.version?.let {
app.versionName?.let {
Text(
text = stringResource(R.string.app_info_version, it),
style = MaterialTheme.typography.bodySmall,
@ -137,7 +136,7 @@ fun AppItem(
)
}
Text(
text = app.`package`,
text = app.componentName.packageName,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 1.dp),
maxLines = 1,
@ -210,20 +209,21 @@ fun AppItem(
for (shortcut in shortcuts) {
val title =
shortcut.launcherShortcut.shortLabel
?: shortcut.launcherShortcut.longLabel
?: continue
shortcut.labelOverride ?: shortcut.label
val isPinned by remember(shortcut) { viewModel.isShortcutPinned(shortcut) }.collectAsState(
false
)
val icon =
val iconSizePx = InputChipDefaults.AvatarSize.toPixels()
val icon by
remember {
viewModel.getShortcutIcon(
context,
shortcut.launcherShortcut
shortcut,
iconSizePx.toInt()
)
}
}.collectAsState(null)
InputChip(
modifier = Modifier.width(IntrinsicSize.Max),
@ -233,19 +233,17 @@ fun AppItem(
},
label = {
Text(
title.toString(),
title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
},
avatar = {
AsyncImage(
model = icon,
contentDescription = null,
modifier = Modifier
.clip(CircleShape)
.size(InputChipDefaults.AvatarSize),
ShapedLauncherIcon(
size = InputChipDefaults.AvatarSize,
icon = { icon },
shape = CircleShape,
)
},
trailingIcon = if (LocalFavoritesEnabled.current) {
@ -311,7 +309,7 @@ fun AppItem(
label = stringResource(R.string.menu_app_info),
icon = Icons.Rounded.Info
) {
app.openAppInfo(context)
app.openAppDetails(context)
})
toolbarActions.add(
@ -429,7 +427,7 @@ fun AppItem(
@Composable
fun AppItemGridPopup(
app: LauncherApp,
app: Application,
show: MutableTransitionState<Boolean>,
animationProgress: Float,
origin: Rect,
@ -454,7 +452,7 @@ fun AppItemGridPopup(
transformOrigin = TransformOrigin(1f, 0f)
)
.offset(
x = lerp(16.dp, 0.dp, animationProgress),
x = lerp(16.dp, 0.dp, animationProgress),
y = lerp(-16.dp, 0.dp, animationProgress)
),
app = app,

View File

@ -41,13 +41,14 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect
import androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.DefaultToolbarAction
@ -83,12 +84,13 @@ fun CalendarItem(
val snackbarHostState = LocalSnackbarHostState.current
val darkMode = LocalDarkTheme.current
val secondaryColor = MaterialTheme.colorScheme.secondary
Row(
modifier = modifier
.drawBehind {
val color = TonalPalette
.fromInt(calendar.color)
.fromInt(calendar.color ?: secondaryColor.toArgb())
.tone(
if (darkMode) 80 else 40
)
@ -151,7 +153,7 @@ fun CalendarItem(
style = MaterialTheme.typography.bodySmall
)
}
if (calendar.description.isNotBlank()) {
if (calendar.description != null) {
Row(
Modifier
.fillMaxWidth(),
@ -163,7 +165,7 @@ fun CalendarItem(
contentDescription = null
)
Text(
text = calendar.description,
text = calendar.description!!,
style = MaterialTheme.typography.bodySmall
)
}
@ -185,7 +187,7 @@ fun CalendarItem(
)
}
}
if (calendar.location.isNotBlank()) {
if (calendar.location != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@ -200,7 +202,7 @@ fun CalendarItem(
contentDescription = null
)
Text(
text = calendar.location,
text = calendar.location!!,
style = MaterialTheme.typography.bodySmall
)
}
@ -320,8 +322,7 @@ fun CalendarItemGridPopup(
) {
CalendarItem(
modifier = Modifier
.fillMaxWidth()
.background(Color(calendar.color).copy(alpha = 1f - animationProgress)),
.fillMaxWidth(),
calendar = calendar,
showDetails = true,
onBack = onDismiss

View File

@ -4,27 +4,22 @@ import android.content.Context
import android.content.pm.LauncherApps
import android.content.pm.ShortcutInfo
import android.graphics.drawable.Drawable
import android.service.notification.StatusBarNotification
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.geometry.Rect
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.getSystemService
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
import de.mm20.launcher2.badges.BadgeService
import de.mm20.launcher2.files.FileRepository
import de.mm20.launcher2.icons.IconService
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.notifications.Notification
import de.mm20.launcher2.notifications.NotificationRepository
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.File
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.Application
import de.mm20.launcher2.services.favorites.FavoritesService
import de.mm20.launcher2.services.tags.TagsService
import de.mm20.launcher2.ui.launcher.search.ListItemViewModel
@ -33,9 +28,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@ -46,7 +43,6 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
private val tagsService: TagsService by inject()
private val notificationRepository: NotificationRepository by inject()
private val appShortcutRepository: AppShortcutRepository by inject()
private val fileRepository: FileRepository by inject()
private val permissionsManager: PermissionsManager by inject()
private val searchable = MutableStateFlow<SavableSearchable?>(null)
@ -93,13 +89,19 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
}
val notifications = searchable.flatMapLatest { searchable ->
if (searchable !is LauncherApp) emptyFlow()
else notificationRepository.notifications.map { it.filter { it.packageName == searchable.`package` && !it.isGroupSummary } }
if (searchable !is Application) emptyFlow()
else notificationRepository.notifications.map { it.filter { it.packageName == searchable.componentName.packageName && !it.isGroupSummary } }
}
val shortcuts = searchable.map {
if (it !is LauncherApp) emptyList()
else appShortcutRepository.getShortcutsForActivity(it.launcherActivityInfo, 5)
if (it !is Application) emptyList()
else appShortcutRepository
.findMany(
componentName = it.componentName,
user = it.user,
manifest = true,
dynamic = true,
).first()
}
open fun launch(context: Context, bounds: Rect? = null): Boolean {
@ -120,7 +122,7 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
if (searchable.launch(context, bundle)) {
favoritesService.reportLaunch(searchable)
return true
} else if (searchable is LauncherApp || searchable is AppShortcut) {
} else if (searchable is Application || searchable is AppShortcut) {
favoritesService.reset(searchable)
}
return false
@ -130,9 +132,8 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
notificationRepository.cancelNotification(notification)
}
fun getShortcutIcon(context: Context, shortcut: ShortcutInfo): Drawable? {
val launcherApps = context.getSystemService<LauncherApps>() ?: return null
return launcherApps.getShortcutIconDrawable(shortcut, 0)
fun getShortcutIcon(context: Context, shortcut: AppShortcut, size: Int): Flow<LauncherIcon?> {
return iconService.getIcon(shortcut, size)
}
fun isShortcutPinned(shortcut: AppShortcut): Flow<Boolean> {
@ -151,10 +152,18 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
shortcut.launch(context, null)
}
fun delete() {
fun delete(context: Context) {
val searchable = searchable.value ?: return
if (searchable is File) fileRepository.deleteFile(searchable)
if (searchable is LauncherShortcut) appShortcutRepository.removePinnedShortcut(searchable)
if (searchable is File) {
viewModelScope.launch {
searchable.delete(context.applicationContext)
}
}
if (searchable is AppShortcut) {
viewModelScope.launch {
searchable.delete(context.applicationContext)
}
}
favoritesService.reset(searchable)
}

View File

@ -1,7 +1,5 @@
package de.mm20.launcher2.ui.launcher.search.common.grid
import android.content.ComponentName
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.MutableTransitionState
@ -42,18 +40,17 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.search.data.Contact
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.search.data.Wikipedia
import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.search.Article
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.Website
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.component.LocalIconShape
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
@ -66,13 +63,12 @@ import de.mm20.launcher2.ui.launcher.search.files.FileItemGridPopup
import de.mm20.launcher2.ui.launcher.search.listItemViewModel
import de.mm20.launcher2.ui.launcher.search.shortcut.ShortcutItemGridPopup
import de.mm20.launcher2.ui.launcher.search.website.WebsiteItemGridPopup
import de.mm20.launcher2.ui.launcher.search.wikipedia.WikipediaItemGridPopup
import de.mm20.launcher2.ui.launcher.search.wikipedia.ArticleItemGridPopup
import de.mm20.launcher2.ui.launcher.transitions.EnterHomeTransitionParams
import de.mm20.launcher2.ui.launcher.transitions.HandleEnterHomeTransition
import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.locals.LocalWindowSize
import de.mm20.launcher2.ui.overlays.Overlay
import kotlin.math.min
import kotlin.math.pow
@ -121,9 +117,9 @@ fun GridItem(
val windowSize = LocalWindowSize.current
if (item is LauncherApp) {
if (item is Application) {
HandleEnterHomeTransition {
val cn = ComponentName(item.`package`, item.activity)
val cn = item.componentName
if (
it.componentName == cn &&
bounds.right > 0f && bounds.left < windowSize.width &&
@ -240,7 +236,7 @@ fun ItemPopup(origin: Rect, searchable: Searchable, onDismissRequest: () -> Unit
)
) {
when (searchable) {
is LauncherApp -> {
is Application -> {
AppItemGridPopup(
app = searchable,
show = show,
@ -264,9 +260,9 @@ fun ItemPopup(origin: Rect, searchable: Searchable, onDismissRequest: () -> Unit
)
}
is Wikipedia -> {
WikipediaItemGridPopup(
wikipedia = searchable,
is Article -> {
ArticleItemGridPopup(
article = searchable,
show = show,
animationProgress = animationProgress.value,
origin = origin,

View File

@ -9,9 +9,11 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.*
import de.mm20.launcher2.ui.component.InnerCard
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.search.calendar.CalendarItem

View File

@ -17,16 +17,20 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Message
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Email
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.MoreHoriz
import androidx.compose.material.icons.rounded.OpenInNew
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material.icons.rounded.Whatsapp
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
@ -36,6 +40,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
@ -49,7 +54,8 @@ import androidx.compose.ui.unit.roundToIntRect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.Contact
import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.ContactInfoType
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.Chip
@ -144,164 +150,38 @@ fun ContactItem(
}
AnimatedVisibility(showDetails) {
val groups = remember {
contact.contactInfos.groupBy { it.type }
}
Column {
if (contact.phones.isNotEmpty()) {
for ((type, items) in groups) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Call, contentDescription = null)
Icon(when(type) {
ContactInfoType.Phone -> Icons.Rounded.Call
ContactInfoType.Message -> Icons.AutoMirrored.Rounded.Message
ContactInfoType.Email -> Icons.Rounded.Email
ContactInfoType.Postal -> Icons.Rounded.Home
ContactInfoType.Telegram -> Icons.Rounded.Telegram
ContactInfoType.Whatsapp -> Icons.Rounded.Whatsapp
ContactInfoType.Signal -> Icons.Rounded.Signal
ContactInfoType.Other -> Icons.Rounded.MoreHoriz
}, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.phones.toList()) {
items(items.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(it.data))
)
}
)
}
}
}
}
if (contact.emails.isNotEmpty()) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Email, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.emails.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(it.data))
)
}
)
}
}
}
}
if (contact.signal.isNotEmpty()) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Signal, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.signal.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(it.data))
)
}
)
}
}
}
}
if (contact.telegram.isNotEmpty()) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Telegram, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.telegram.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(it.data))
)
}
)
}
}
}
}
if (contact.whatsapp.isNotEmpty()) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.WhatsApp, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.whatsapp.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(it.data))
)
}
)
}
}
}
}
if (contact.postals.isNotEmpty()) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Place, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.postals.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(Uri.parse(it.data))
)
context.tryStartActivity(it.intent)
}
)
}

View File

@ -47,7 +47,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.File
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.DefaultToolbarAction
@ -236,7 +236,7 @@ fun FileItem(
onDismissRequest = { showConfirmDialog = false },
confirmButton = {
TextButton(onClick = {
viewModel.delete()
viewModel.delete(context)
showConfirmDialog = false
}) {
Text(stringResource(android.R.string.ok))

View File

@ -53,10 +53,7 @@ import androidx.compose.ui.unit.roundToIntRect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.LauncherShortcut
import de.mm20.launcher2.search.data.LegacyShortcut
import de.mm20.launcher2.search.data.UnavailableShortcut
import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.DefaultToolbarAction
@ -101,7 +98,7 @@ fun AppShortcutItem(
Column(
modifier = modifier
) {
AnimatedVisibility(showDetails && shortcut is UnavailableShortcut) {
AnimatedVisibility(showDetails && shortcut.isUnavailable) {
MissingPermissionBanner(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
text = stringResource(R.string.shortcut_unavailable_description, stringResource(R.string.app_name)),
@ -213,7 +210,7 @@ fun AppShortcutItem(
action = { sheetManager.showCustomizeSearchableModal(shortcut) }
))
if (shortcut is LauncherShortcut && shortcut.launcherShortcut.isPinned) {
if (shortcut.canDelete) {
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_delete),
icon = Icons.Rounded.Delete,
@ -276,7 +273,7 @@ fun AppShortcutItem(
text = { Text(stringResource(R.string.alert_delete_shortcut, shortcut.label)) },
confirmButton = {
TextButton(onClick = {
viewModel.delete()
viewModel.delete(context)
requestDelete = false
}) {
Text(stringResource(android.R.string.ok))
@ -330,9 +327,3 @@ fun ShortcutItemGridPopup(
}
}
val AppShortcut.packageName: String?
get() = when (this) {
is LegacyShortcut -> intent.`package`
is LauncherShortcut -> launcherShortcut.`package`
else -> null
}

View File

@ -32,7 +32,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect
import coil.compose.AsyncImage
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.search.Website
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.Toolbar
@ -64,13 +64,13 @@ fun WebsiteItem(
viewModel.launch(context)
}
) {
if (website.image.isNotBlank()) {
if (website.imageUrl != null) {
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(MaterialTheme.colorScheme.secondaryContainer),
model = website.image,
model = website.imageUrl,
contentScale = ContentScale.Crop,
contentDescription = null
)
@ -93,7 +93,7 @@ fun WebsiteItem(
}
Text(
modifier = Modifier.padding(vertical = 8.dp),
text = website.description,
text = website.description ?: "",
style = MaterialTheme.typography.bodySmall
)
}

View File

@ -32,7 +32,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect
import coil.compose.AsyncImage
import de.mm20.launcher2.search.data.Wikipedia
import de.mm20.launcher2.search.Article
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.Toolbar
@ -46,18 +46,18 @@ import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.utils.htmlToAnnotatedString
@Composable
fun WikipediaItem(
fun ArticleItem(
modifier: Modifier = Modifier,
wikipedia: Wikipedia,
article: Article,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${wikipedia.key}")
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${article.key}")
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
LaunchedEffect(wikipedia, iconSize) {
viewModel.init(wikipedia, iconSize.toInt())
LaunchedEffect(article, iconSize) {
viewModel.init(article, iconSize.toInt())
}
Column(
@ -65,13 +65,13 @@ fun WikipediaItem(
viewModel.launch(context)
}
) {
if (!wikipedia.image.isNullOrEmpty()) {
if (!article.imageUrl.isNullOrEmpty()) {
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(MaterialTheme.colorScheme.secondaryContainer),
model = wikipedia.image,
model = article.imageUrl,
contentScale = ContentScale.Crop,
contentDescription = null
)
@ -80,7 +80,7 @@ fun WikipediaItem(
modifier = Modifier.padding(16.dp),
) {
Text(
text = wikipedia.label,
text = article.label,
style = MaterialTheme.typography.titleLarge
)
val tags by viewModel.tags.collectAsState(emptyList())
@ -100,7 +100,7 @@ fun WikipediaItem(
)
Text(
modifier = Modifier.padding(vertical = 8.dp),
text = htmlToAnnotatedString(wikipedia.text),
text = htmlToAnnotatedString(article.text),
style = MaterialTheme.typography.bodySmall
)
}
@ -134,7 +134,7 @@ fun WikipediaItem(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share,
action = {
wikipedia.share(context)
article.share(context)
}
)
)
@ -143,7 +143,7 @@ fun WikipediaItem(
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Edit,
action = { sheetManager.showCustomizeSearchableModal(wikipedia) }
action = { sheetManager.showCustomizeSearchableModal(article) }
))
Toolbar(
@ -160,8 +160,8 @@ fun WikipediaItem(
}
@Composable
fun WikipediaItemGridPopup(
wikipedia: Wikipedia,
fun ArticleItemGridPopup(
article: Article,
show: MutableTransitionState<Boolean>,
animationProgress: Float,
origin: Rect,
@ -178,10 +178,10 @@ fun WikipediaItemGridPopup(
shrinkTowards = Alignment.Center,
) { origin.roundToIntRect().size },
) {
WikipediaItem(
ArticleItem(
modifier = Modifier
.fillMaxWidth(),
wikipedia = wikipedia,
article = article,
onBack = onDismiss
)
}

View File

@ -74,7 +74,7 @@ import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.search.data.UserCalendar
import de.mm20.launcher2.calendar.UserCalendar
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.LargeMessage

View File

@ -3,6 +3,7 @@ package de.mm20.launcher2.ui.launcher.sheets
import android.app.Activity
import android.content.Context
import android.content.pm.LauncherApps
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
@ -64,6 +65,7 @@ import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -73,6 +75,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable
@ -647,7 +650,6 @@ fun ShortcutPicker(viewModel: EditFavoritesSheetVM, paddingValues: PaddingValues
}
val iconSize = 48.dp.toPixels().roundToInt()
val activity = LocalLifecycleOwner.current as AppCompatActivity
LazyColumn(
contentPadding = paddingValues
@ -664,31 +666,28 @@ fun ShortcutPicker(viewModel: EditFavoritesSheetVM, paddingValues: PaddingValues
}
}
items(shortcutActivities) {
val icon by remember(it.key) { viewModel.getIcon(it, iconSize) }.collectAsState(null)
val badge by remember(it.key) { viewModel.getBadge(it) }.collectAsState(null)
val icon by remember(it) { it.getIcon(context) }.collectAsState(null)
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
onClick = {
val launcherApps =
context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
val sender =
launcherApps.getShortcutConfigActivityIntent(it.launcherActivityInfo)
?: return@OutlinedCard
activityLauncher.launch(IntentSenderRequest.Builder(sender).build(), null)
val intent = it.getIntent(context) ?: return@OutlinedCard run {
Log.e("MM20", "Couldn't get intent for shortcut")
}
activityLauncher.launch(IntentSenderRequest.Builder(intent).build(), null)
}) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ShapedLauncherIcon(
size = 48.dp,
icon = { icon },
badge = { badge },
AsyncImage(
model = icon,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Text(
text = it.labelOverride ?: it.label,
text = it.label,
modifier = Modifier.padding(start = 16.dp),
style = MaterialTheme.typography.titleSmall
)

View File

@ -19,7 +19,7 @@ import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.appshortcuts.AppShortcut
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.data.Tag
import de.mm20.launcher2.services.favorites.FavoritesService
@ -210,7 +210,7 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
fun createShortcut(context: Context, data: Intent?) {
data ?: return cancelPickShortcut()
val shortcut = AppShortcut.fromPinRequestIntent(context, data)
val shortcut = AppShortcut(context, data)
if (shortcut == null) {
cancelPickShortcut()

View File

@ -9,11 +9,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.calendar.CalendarRepository
import de.mm20.launcher2.searchable.SearchableRepository
import de.mm20.launcher2.searchable.SavableSearchableRepository
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.services.favorites.FavoritesService
import de.mm20.launcher2.widgets.CalendarWidget
import de.mm20.launcher2.widgets.CalendarWidgetConfig
@ -34,14 +34,14 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
private val calendarRepository: CalendarRepository by inject()
private val favoritesService: FavoritesService by inject()
private val searchableRepository: SearchableRepository by inject()
private val searchableRepository: SavableSearchableRepository by inject()
private val widgetConfig = MutableStateFlow(CalendarWidgetConfig())
val calendarEvents = mutableStateOf<List<CalendarEvent>>(emptyList())
val pinnedCalendarEvents =
favoritesService.getFavorites(
includeTypes = listOf(CalendarEvent.Domain),
includeTypes = listOf("calendar"),
automaticallySorted = true,
manuallySorted = true,
).stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
@ -164,12 +164,14 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
suspend fun onActive() {
selectDate(LocalDate.now())
widgetConfig.collectLatest { config ->
calendarRepository.getUpcomingEvents(
calendarRepository.findMany(
from = System.currentTimeMillis(),
to = System.currentTimeMillis() + 14 * 24 * 60 * 60 * 1000L,
excludeAllDayEvents = !config.allDayEvents,
excludeCalendars = config.excludedCalendarIds,
).collectLatest { events ->
searchableRepository.getKeys(
includeTypes = listOf(CalendarEvent.Domain),
includeTypes = listOf("calendar"),
hidden = true,
limit = 9999,
).collectLatest { hidden ->

View File

@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.searchable.SearchableRepository
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings.ClockWidgetSettings.ClockWidgetLayout
import de.mm20.launcher2.services.favorites.FavoritesService

View File

@ -3,13 +3,13 @@ package de.mm20.launcher2.ui.settings.debug
import androidx.lifecycle.ViewModel
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.icons.IconService
import de.mm20.launcher2.searchable.SearchableRepository
import de.mm20.launcher2.searchable.SavableSearchableRepository
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class DebugSettingsScreenVM: ViewModel(), KoinComponent {
private val searchableRepository: SearchableRepository by inject()
private val searchableRepository: SavableSearchableRepository by inject()
private val customAttributesRepository: CustomAttributesRepository by inject()
private val iconService: IconService by inject()
suspend fun cleanUpDatabase(): Int {

View File

@ -3,7 +3,7 @@ package de.mm20.launcher2.ui.settings.gestures
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.searchable.SearchableRepository
import de.mm20.launcher2.searchable.SavableSearchableRepository
import de.mm20.launcher2.icons.IconService
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.permissions.PermissionGroup
@ -24,7 +24,7 @@ import org.koin.core.component.inject
class GestureSettingsScreenVM : ViewModel(), KoinComponent {
private val dataStore: LauncherDataStore by inject()
private val permissionsManager: PermissionsManager by inject()
private val searchableRepository: SearchableRepository by inject()
private val searchableRepository: SavableSearchableRepository by inject()
private val iconService: IconService by inject()
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Accessibility)

View File

@ -1,20 +1,17 @@
package de.mm20.launcher2.ui.settings.hiddenitems
import android.content.ComponentName
import android.content.Context
import android.content.pm.LauncherApps
import android.os.Bundle
import androidx.core.content.getSystemService
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.applications.AppRepository
import de.mm20.launcher2.searchable.SearchableRepository
import de.mm20.launcher2.searchable.SavableSearchableRepository
import de.mm20.launcher2.icons.IconService
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.Application
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@ -30,16 +27,16 @@ import org.koin.core.component.inject
class HiddenItemsSettingsScreenVM : ViewModel(), KoinComponent {
private val appRepository: AppRepository by inject()
private val searchableRepository: SearchableRepository by inject()
private val searchableRepository: SavableSearchableRepository by inject()
private val iconService: IconService by inject()
private val dataStore: LauncherDataStore by inject()
val allApps = appRepository.getAllInstalledApps().map {
val allApps = appRepository.findMany().map {
withContext(Dispatchers.Default) { it.sorted() }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
val hiddenItems: StateFlow<List<SavableSearchable>> = flow {
val hidden =
searchableRepository.get(hidden = true).first().filter { it !is LauncherApp }.sorted()
searchableRepository.get(hidden = true).first().filter { it !is Application }.sorted()
emit(hidden)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
@ -67,15 +64,8 @@ class HiddenItemsSettingsScreenVM : ViewModel(), KoinComponent {
searchable.launch(context, bundle)
}
fun openAppInfo(context: Context, app: LauncherApp) {
val launcherApps = context.getSystemService<LauncherApps>()!!
launcherApps.startAppDetailsActivity(
ComponentName(app.`package`, app.activity),
app.getUser(),
null,
null
)
fun openAppInfo(context: Context, app: Application) {
app.openAppDetails(context)
}
val hiddenItemsButton = dataStore.data.map { it.searchBar.hiddenItemsButton }

View File

@ -12,7 +12,7 @@ import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.services.favorites.FavoritesService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@ -224,7 +224,7 @@ class IconsSettingsScreenVM(
fun getPreviewIcons(size: Int): Flow<List<LauncherIcon?>> {
return columnCount.flatMapLatest { cols ->
favoritesService.getFavorites(
includeTypes = listOf(LauncherApp.Domain),
includeTypes = listOf("app"),
limit = cols,
manuallySorted = true,
automaticallySorted = true,

View File

@ -12,6 +12,7 @@ import de.mm20.launcher2.music.MusicService
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.AppProfile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@ -49,8 +50,8 @@ class MediaIntegrationSettingsScreenVM : ViewModel(), KoinComponent {
loading.value = true
viewModelScope.launch(Dispatchers.Default) {
val musicApps = musicService.getInstalledPlayerPackages()
val allApps = appRepository.getAllInstalledApps().first().filter { it.isMainProfile }
.distinctBy { it.`package` }
val allApps = appRepository.findMany().first().filter { it.profile == AppProfile.Personal }
.distinctBy { it.componentName.packageName }
val settings = dataStore.data.map { it.musicWidget }.first()
val allowList = settings.allowListList
val denyList = settings.denyListList
@ -58,10 +59,10 @@ class MediaIntegrationSettingsScreenVM : ViewModel(), KoinComponent {
appList.value = allApps.map {
AppListItem(
label = it.label,
packageName = it.`package`,
isMusicApp = musicApps.contains(it.`package`),
isChecked = allowList.contains(it.`package`) || (!denyList.contains(it.`package`) && musicApps.contains(
it.`package`
packageName = it.componentName.packageName,
isMusicApp = musicApps.contains(it.componentName.packageName),
isChecked = allowList.contains(it.componentName.packageName) || (!denyList.contains(it.componentName.packageName) && musicApps.contains(
it.componentName.packageName
)),
icon = iconService.getIcon(it, (32 * density).roundToInt())
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(10000))

View File

@ -55,7 +55,7 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
viewModelScope.launch(Dispatchers.Default) {
allTags = tagService.getAllTags().first().toSet()
val items = if (tag != null) tagService.getTaggedItems(tag).first() else emptyList()
val apps = appRepository.getAllInstalledApps().first().sorted()
val apps = appRepository.findMany().first().sorted()
taggedItems = items
taggableApps = apps.map { app -> TaggableItem(app, items.any { app.key == it.key }) }
taggableOther = items.mapNotNull { item ->

View File

@ -1,17 +1,18 @@
package de.mm20.launcher2.search.data
package de.mm20.launcher2.search
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import de.mm20.launcher2.appshortcuts.R
import de.mm20.launcher2.base.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 {
interface AppShortcut : SavableSearchable {
val appName: String?
val componentName: ComponentName?
val packageName: String?
override val preferDetailsOverLaunch: Boolean
get() = false
@ -27,10 +28,13 @@ interface AppShortcut: SavableSearchable {
)
}
companion object {
fun fromPinRequestIntent(context: Context, data: Intent): AppShortcut? {
return LauncherShortcut.fromPinRequestIntent(context, data)
?: LegacyShortcut.fromPinRequestIntent(context, data)
}
}
val canDelete: Boolean
get() = false
suspend fun delete(context: Context) {}
val isUnavailable: Boolean
get() = false
val profile: AppProfile
}

View File

@ -0,0 +1,62 @@
package de.mm20.launcher2.search
import android.content.ComponentName
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.os.UserHandle
import androidx.core.content.ContextCompat
import de.mm20.launcher2.base.R
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TintedIconLayer
enum class AppProfile {
Personal,
Work,
}
interface Application: SavableSearchable {
override val preferDetailsOverLaunch: Boolean
get() = false
val componentName: ComponentName
val isSystemApp: Boolean
val isSuspended: Boolean
val profile: AppProfile
val user: UserHandle
val versionName: String?
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())
)
}
val canUninstall: Boolean
fun uninstall(context: Context)
fun openAppDetails(context: Context)
val canShareApk: Boolean
suspend fun shareApkFile(context: Context) {}
fun getStoreDetails(context: Context): StoreLink? = null
fun getActivityInfo(context: Context): ActivityInfo? {
return try {
context.packageManager.getActivityInfo(componentName, 0)
} catch (e: PackageManager.NameNotFoundException) {
null
}
}
}
data class StoreLink(
val label: String,
val url: String
)

View File

@ -0,0 +1,17 @@
package de.mm20.launcher2.search
import android.content.Context
interface Article: SavableSearchable {
val text: String
val imageUrl: String?
val sourceUrl: String
val sourceName: String
val canShare: Boolean
fun share(context: Context) {}
override val preferDetailsOverLaunch: Boolean
get() = false
}

View File

@ -0,0 +1,35 @@
package de.mm20.launcher2.search
import android.content.Context
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TextLayer
import java.text.SimpleDateFormat
interface CalendarEvent: SavableSearchable {
val color: Int?
val startTime: Long
val endTime: Long
val allDay: Boolean
val description: String?
val location: String?
val attendees: List<String>
override val preferDetailsOverLaunch: Boolean
get() = true
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
val df = SimpleDateFormat("dd")
return StaticLauncherIcon(
foregroundLayer = TextLayer(
text = df.format(startTime),
color = color ?: 0,
),
backgroundLayer = ColorLayer(color ?: 0)
)
}
fun openLocation(context: Context) {}
}

View File

@ -0,0 +1,35 @@
package de.mm20.launcher2.search
import android.content.Intent
interface Contact: SavableSearchable {
val firstName: String
val lastName: String
val displayName: String
val summary: String
val contactInfos: Iterable<ContactInfo>
override val preferDetailsOverLaunch: Boolean
get() = true
}
/**
* Type of the contact info
* Acts as a hint for the UI, so that it can display the correct icon and group them accordingly
*/
enum class ContactInfoType {
Phone,
Message,
Email,
Postal,
Telegram,
Whatsapp,
Signal,
Other
}
interface ContactInfo {
val type: ContactInfoType
val label: String
val intent: Intent
}

View File

@ -1,13 +1,12 @@
package de.mm20.launcher2.search.data
package de.mm20.launcher2.search
import android.content.Context
import androidx.core.content.ContextCompat
import de.mm20.launcher2.files.R
import de.mm20.launcher2.base.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.*
import java.util.Locale
interface File : SavableSearchable {
val path: String
@ -21,7 +20,7 @@ interface File : SavableSearchable {
override val preferDetailsOverLaunch: Boolean
get() = false
open val providerIconRes: Int?
val providerIconRes: Int?
get() = null
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {

View File

@ -8,8 +8,6 @@ import de.mm20.launcher2.ktx.romanize
import java.text.Collator
interface SavableSearchable : Searchable, Comparable<SavableSearchable> {
val domain: String
val key: String
val label: String
@ -41,4 +39,11 @@ interface SavableSearchable : Searchable, Comparable<SavableSearchable> {
.compare(label1.romanize(), label2.romanize())
}
val domain: String
fun getSerializer(): SearchableSerializer
interface Companion {
val Domain: String
}
}

View File

@ -1,11 +1,11 @@
package de.mm20.launcher2.search
interface SearchableDeserializer {
fun deserialize(serialized: String): SavableSearchable?
suspend fun deserialize(serialized: String): SavableSearchable?
}
class NullDeserializer: SearchableDeserializer {
override fun deserialize(serialized: String): SavableSearchable? {
override suspend fun deserialize(serialized: String): SavableSearchable? {
return null
}

View File

@ -0,0 +1,8 @@
package de.mm20.launcher2.search
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.Flow
interface SearchableRepository<T : Searchable> {
fun search(query: String): Flow<ImmutableList<T>>
}

View File

@ -0,0 +1,18 @@
package de.mm20.launcher2.search
import android.content.Context
interface Website: SavableSearchable {
val url: String
val description: String?
val imageUrl: String?
val faviconUrl: String?
val color: Int?
override val preferDetailsOverLaunch: Boolean
get() = false
val canShare: Boolean
fun share(context: Context) {}
}

View File

@ -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("")
}
}
}

View File

@ -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())
}

View File

@ -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()

View File

@ -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>()!!

View File

@ -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()
}
}

View File

@ -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()) }
}

View File

@ -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)
}

View File

@ -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())
}
}

View File

@ -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)
}
)
}

View File

@ -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)

View File

@ -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 =

View File

@ -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"

View File

@ -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()) }
}

View File

@ -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 {

View File

@ -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"
}

View File

@ -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) {

View File

@ -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,

View File

@ -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()) }
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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

View File

@ -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()
}
}

View File

@ -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()) }
}

View File

@ -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
)

View File

@ -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)

View File

@ -51,4 +51,5 @@ dependencies {
implementation(project(":core:i18n"))
implementation(project(":core:permissions"))
implementation(project(":core:crashreporter"))
implementation(project(":core:preferences"))
}

View File

@ -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")

View File

@ -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())
}
}
}

View File

@ -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() }
}

View File

@ -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>
}

View 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"
}

View File

@ -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

View File

@ -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()
}
}

View File

@ -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

View File

@ -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"

View File

@ -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

View 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.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"
}

View 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.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"
}

View File

@ -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

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"))

View File

@ -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"
}

View File

@ -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() }
}

View File

@ -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,

View File

@ -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,

View File

@ -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()
}

View File

@ -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"))

View File

@ -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() }
}

View File

@ -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"
}

View File

@ -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) {

View File

@ -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")

View File

@ -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()) }
}

View File

@ -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"
}

View File

@ -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),
)
}

View File

@ -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),
)
}
}

View File

@ -15,6 +15,7 @@ digraph {
":core:ktx" [fillcolor="#94c1ff"];
":core:permissions" [fillcolor="#94c1ff"];
":core:preferences" [fillcolor="#94c1ff"];
":core:shared" [fillcolor="#94c1ff"];
":data:applications" [fillcolor="#fff694"];
":data:appshortcuts" [fillcolor="#fff694"];
":data:calculator" [fillcolor="#fff694"];
@ -22,10 +23,11 @@ digraph {
":data:contacts" [fillcolor="#fff694"];
":data:currencies" [fillcolor="#fff694"];
":data:customattrs" [fillcolor="#fff694"];
":data:favorites" [fillcolor="#fff694"];
":data:files" [fillcolor="#fff694"];
":data:notifications" [fillcolor="#fff694"];
":data:search-actions" [fillcolor="#fff694"];
":data:searchable" [fillcolor="#fff694"];
":data:themes" [fillcolor="#fff694"];
":data:unitconverter" [fillcolor="#fff694"];
":data:weather" [fillcolor="#fff694"];
":data:websites" [fillcolor="#fff694"];
@ -37,9 +39,11 @@ digraph {
":libs:nextcloud" [fillcolor="#ad94ff"];
":libs:owncloud" [fillcolor="#ad94ff"];
":libs:webdav" [fillcolor="#ad94ff"];
":plugins:sdk" [];
":services:accounts" [fillcolor="#ff9498"];
":services:backup" [fillcolor="#ff9498"];
":services:badges" [fillcolor="#ff9498"];
":services:favorites" [fillcolor="#ff9498"];
":services:global-actions" [fillcolor="#ff9498"];
":services:icons" [fillcolor="#ff9498"];
":services:music" [fillcolor="#ff9498"];
@ -64,13 +68,13 @@ digraph {
":app:app" -> ":core:crashreporter" [style=dotted]
":app:app" -> ":data:currencies" [style=dotted]
":app:app" -> ":data:customattrs" [style=dotted]
":app:app" -> ":data:favorites" [style=dotted]
":app:app" -> ":data:searchable" [style=dotted]
":app:app" -> ":data:themes" [style=dotted]
":app:app" -> ":data:files" [style=dotted]
":app:app" -> ":libs:g-services" [style=dotted]
":app:app" -> ":core:i18n" [style=dotted]
":app:app" -> ":services:icons" [style=dotted]
":app:app" -> ":core:ktx" [style=dotted]
":app:app" -> ":libs:ms-services" [style=dotted]
":app:app" -> ":services:music" [style=dotted]
":app:app" -> ":libs:nextcloud" [style=dotted]
":app:app" -> ":data:notifications" [style=dotted]
@ -89,6 +93,7 @@ digraph {
":app:app" -> ":data:search-actions" [style=dotted]
":app:app" -> ":services:global-actions" [style=dotted]
":app:app" -> ":services:widgets" [style=dotted]
":app:app" -> ":services:favorites" [style=dotted]
":app:ui" -> ":app:ui"
":app:ui" -> ":libs:material-color-utilities" [style=dotted]
":app:ui" -> ":core:base" [style=dotted]
@ -107,7 +112,8 @@ digraph {
":app:ui" -> ":data:calculator" [style=dotted]
":app:ui" -> ":data:files" [style=dotted]
":app:ui" -> ":data:widgets" [style=dotted]
":app:ui" -> ":data:favorites" [style=dotted]
":app:ui" -> ":data:searchable" [style=dotted]
":app:ui" -> ":data:themes" [style=dotted]
":app:ui" -> ":data:wikipedia" [style=dotted]
":app:ui" -> ":services:badges" [style=dotted]
":app:ui" -> ":core:crashreporter" [style=dotted]
@ -118,112 +124,50 @@ digraph {
":app:ui" -> ":data:unitconverter" [style=dotted]
":app:ui" -> ":libs:nextcloud" [style=dotted]
":app:ui" -> ":libs:g-services" [style=dotted]
":app:ui" -> ":libs:ms-services" [style=dotted]
":app:ui" -> ":libs:owncloud" [style=dotted]
":app:ui" -> ":services:accounts" [style=dotted]
":app:ui" -> ":services:backup" [style=dotted]
":app:ui" -> ":data:search-actions" [style=dotted]
":app:ui" -> ":services:global-actions" [style=dotted]
":app:ui" -> ":services:widgets" [style=dotted]
":core:base" -> ":core:base"
":core:base" -> ":core:ktx" [style=dotted]
":core:base" -> ":core:i18n" [style=dotted]
":core:compat" -> ":core:compat"
":core:crashreporter" -> ":core:crashreporter"
":core:crashreporter" -> ":core:base" [style=dotted]
":app:ui" -> ":services:favorites" [style=dotted]
":core:shared" -> ":core:shared"
":core:database" -> ":core:database"
":core:database" -> ":core:i18n" [style=dotted]
":core:database" -> ":core:ktx" [style=dotted]
":core:i18n" -> ":core:i18n"
":core:ktx" -> ":core:ktx"
":core:permissions" -> ":core:permissions"
":core:permissions" -> ":core:ktx" [style=dotted]
":core:permissions" -> ":core:base" [style=dotted]
":core:permissions" -> ":core:crashreporter" [style=dotted]
":core:database" -> ":core:preferences" [style=dotted]
":core:preferences" -> ":core:preferences"
":core:preferences" -> ":core:ktx" [style=dotted]
":core:preferences" -> ":core:i18n" [style=dotted]
":core:preferences" -> ":core:base" [style=dotted]
":core:preferences" -> ":core:crashreporter" [style=dotted]
":core:preferences" -> ":libs:material-color-utilities" [style=dotted]
":data:applications" -> ":data:applications"
":data:applications" -> ":core:base" [style=dotted]
":data:applications" -> ":core:ktx" [style=dotted]
":data:applications" -> ":core:compat" [style=dotted]
":data:appshortcuts" -> ":data:appshortcuts"
":data:appshortcuts" -> ":data:applications" [style=dotted]
":data:appshortcuts" -> ":core:permissions" [style=dotted]
":data:appshortcuts" -> ":core:base" [style=dotted]
":data:appshortcuts" -> ":core:ktx" [style=dotted]
":data:calculator" -> ":data:calculator"
":data:calculator" -> ":core:base" [style=dotted]
":core:permissions" -> ":core:permissions"
":core:permissions" -> ":core:ktx" [style=dotted]
":core:permissions" -> ":core:base" [style=dotted]
":core:permissions" -> ":core:crashreporter" [style=dotted]
":core:compat" -> ":core:compat"
":core:crashreporter" -> ":core:crashreporter"
":core:crashreporter" -> ":core:base" [style=dotted]
":core:i18n" -> ":core:i18n"
":core:ktx" -> ":core:ktx"
":core:base" -> ":core:base"
":core:base" -> ":core:ktx" [style=dotted]
":core:base" -> ":core:i18n" [style=dotted]
":core:base" -> ":libs:material-color-utilities" [style=dotted]
":data:calendar" -> ":data:calendar"
":data:calendar" -> ":core:ktx" [style=dotted]
":data:calendar" -> ":core:base" [style=dotted]
":data:calendar" -> ":core:permissions" [style=dotted]
":data:calendar" -> ":libs:material-color-utilities" [style=dotted]
":data:contacts" -> ":data:contacts"
":data:contacts" -> ":core:ktx" [style=dotted]
":data:contacts" -> ":core:base" [style=dotted]
":data:contacts" -> ":core:permissions" [style=dotted]
":data:currencies" -> ":data:currencies"
":data:currencies" -> ":core:ktx" [style=dotted]
":data:currencies" -> ":core:i18n" [style=dotted]
":data:currencies" -> ":core:database" [style=dotted]
":data:currencies" -> ":core:crashreporter" [style=dotted]
":data:customattrs" -> ":data:customattrs"
":data:customattrs" -> ":core:database" [style=dotted]
":data:customattrs" -> ":core:base" [style=dotted]
":data:customattrs" -> ":core:ktx" [style=dotted]
":data:customattrs" -> ":core:crashreporter" [style=dotted]
":data:customattrs" -> ":data:favorites" [style=dotted]
":data:favorites" -> ":data:favorites"
":data:favorites" -> ":core:base" [style=dotted]
":data:favorites" -> ":data:calendar" [style=dotted]
":data:favorites" -> ":core:database" [style=dotted]
":data:favorites" -> ":core:preferences" [style=dotted]
":data:favorites" -> ":data:applications" [style=dotted]
":data:favorites" -> ":data:appshortcuts" [style=dotted]
":data:favorites" -> ":data:contacts" [style=dotted]
":data:favorites" -> ":core:ktx" [style=dotted]
":data:favorites" -> ":data:files" [style=dotted]
":data:favorites" -> ":data:websites" [style=dotted]
":data:favorites" -> ":data:wikipedia" [style=dotted]
":data:favorites" -> ":services:badges" [style=dotted]
":data:favorites" -> ":core:crashreporter" [style=dotted]
":data:files" -> ":data:files"
":data:files" -> ":core:base" [style=dotted]
":data:files" -> ":core:ktx" [style=dotted]
":data:files" -> ":libs:ms-services" [style=dotted]
":data:files" -> ":libs:g-services" [style=dotted]
":data:files" -> ":libs:nextcloud" [style=dotted]
":data:files" -> ":libs:owncloud" [style=dotted]
":data:files" -> ":core:i18n" [style=dotted]
":data:files" -> ":core:permissions" [style=dotted]
":data:files" -> ":core:crashreporter" [style=dotted]
":data:notifications" -> ":data:notifications"
":data:notifications" -> ":core:permissions" [style=dotted]
":data:search-actions" -> ":data:search-actions"
":data:search-actions" -> ":core:base" [style=dotted]
":data:search-actions" -> ":core:database" [style=dotted]
":data:search-actions" -> ":core:ktx" [style=dotted]
":data:search-actions" -> ":core:preferences" [style=dotted]
":data:search-actions" -> ":core:crashreporter" [style=dotted]
":data:unitconverter" -> ":data:unitconverter"
":data:unitconverter" -> ":core:preferences" [style=dotted]
":data:unitconverter" -> ":data:currencies" [style=dotted]
":data:unitconverter" -> ":core:base" [style=dotted]
":data:unitconverter" -> ":core:i18n" [style=dotted]
":data:weather" -> ":data:weather"
":data:weather" -> ":core:database" [style=dotted]
":data:weather" -> ":core:ktx" [style=dotted]
":data:weather" -> ":core:crashreporter" [style=dotted]
":data:weather" -> ":core:preferences" [style=dotted]
":data:weather" -> ":core:permissions" [style=dotted]
":data:weather" -> ":core:i18n" [style=dotted]
":data:websites" -> ":data:websites"
":data:websites" -> ":core:base" [style=dotted]
":data:websites" -> ":core:ktx" [style=dotted]
":data:calculator" -> ":data:calculator"
":data:calculator" -> ":core:base" [style=dotted]
":data:appshortcuts" -> ":data:appshortcuts"
":data:appshortcuts" -> ":data:applications" [style=dotted]
":data:appshortcuts" -> ":core:permissions" [style=dotted]
":data:appshortcuts" -> ":core:base" [style=dotted]
":data:appshortcuts" -> ":core:ktx" [style=dotted]
":data:appshortcuts" -> ":core:crashreporter" [style=dotted]
":data:widgets" -> ":data:widgets"
":data:widgets" -> ":data:weather" [style=dotted]
":data:widgets" -> ":data:calendar" [style=dotted]
@ -233,40 +177,79 @@ digraph {
":data:widgets" -> ":core:preferences" [style=dotted]
":data:widgets" -> ":core:database" [style=dotted]
":data:widgets" -> ":core:crashreporter" [style=dotted]
":data:searchable" -> ":data:searchable"
":data:searchable" -> ":core:base" [style=dotted]
":data:searchable" -> ":data:calendar" [style=dotted]
":data:searchable" -> ":core:database" [style=dotted]
":data:searchable" -> ":core:preferences" [style=dotted]
":data:searchable" -> ":core:ktx" [style=dotted]
":data:searchable" -> ":data:wikipedia" [style=dotted]
":data:searchable" -> ":services:badges" [style=dotted]
":data:searchable" -> ":core:crashreporter" [style=dotted]
":data:unitconverter" -> ":data:unitconverter"
":data:unitconverter" -> ":core:preferences" [style=dotted]
":data:unitconverter" -> ":data:currencies" [style=dotted]
":data:unitconverter" -> ":core:base" [style=dotted]
":data:unitconverter" -> ":core:i18n" [style=dotted]
":data:themes" -> ":data:themes"
":data:themes" -> ":core:base" [style=dotted]
":data:themes" -> ":core:database" [style=dotted]
":data:themes" -> ":core:crashreporter" [style=dotted]
":data:themes" -> ":libs:material-color-utilities" [style=dotted]
":data:customattrs" -> ":data:customattrs"
":data:customattrs" -> ":core:database" [style=dotted]
":data:customattrs" -> ":core:base" [style=dotted]
":data:customattrs" -> ":core:ktx" [style=dotted]
":data:customattrs" -> ":core:crashreporter" [style=dotted]
":data:customattrs" -> ":data:searchable" [style=dotted]
":data:weather" -> ":data:weather"
":data:weather" -> ":core:database" [style=dotted]
":data:weather" -> ":core:ktx" [style=dotted]
":data:weather" -> ":core:crashreporter" [style=dotted]
":data:weather" -> ":core:preferences" [style=dotted]
":data:weather" -> ":core:permissions" [style=dotted]
":data:weather" -> ":core:i18n" [style=dotted]
":data:files" -> ":data:files"
":data:files" -> ":core:base" [style=dotted]
":data:files" -> ":core:ktx" [style=dotted]
":data:files" -> ":libs:g-services" [style=dotted]
":data:files" -> ":libs:nextcloud" [style=dotted]
":data:files" -> ":libs:owncloud" [style=dotted]
":data:files" -> ":core:i18n" [style=dotted]
":data:files" -> ":core:permissions" [style=dotted]
":data:files" -> ":core:crashreporter" [style=dotted]
":data:files" -> ":core:preferences" [style=dotted]
":data:websites" -> ":data:websites"
":data:websites" -> ":core:base" [style=dotted]
":data:websites" -> ":core:ktx" [style=dotted]
":data:search-actions" -> ":data:search-actions"
":data:search-actions" -> ":core:base" [style=dotted]
":data:search-actions" -> ":core:database" [style=dotted]
":data:search-actions" -> ":core:ktx" [style=dotted]
":data:search-actions" -> ":core:preferences" [style=dotted]
":data:search-actions" -> ":core:crashreporter" [style=dotted]
":data:wikipedia" -> ":data:wikipedia"
":data:wikipedia" -> ":core:preferences" [style=dotted]
":data:wikipedia" -> ":core:base" [style=dotted]
":data:wikipedia" -> ":core:ktx" [style=dotted]
":data:wikipedia" -> ":core:crashreporter" [style=dotted]
":libs:g-services" -> ":libs:g-services"
":libs:g-services" -> ":core:i18n" [style=dotted]
":libs:g-services" -> ":core:crashreporter" [style=dotted]
":libs:material-color-utilities" -> ":libs:material-color-utilities"
":libs:ms-services" -> ":libs:ms-services"
":libs:ms-services" -> ":core:crashreporter" [style=dotted]
":libs:nextcloud" -> ":libs:webdav"
":libs:nextcloud" -> ":libs:nextcloud"
":libs:nextcloud" -> ":core:i18n" [style=dotted]
":libs:owncloud" -> ":libs:webdav"
":libs:owncloud" -> ":libs:owncloud"
":libs:owncloud" -> ":core:crashreporter" [style=dotted]
":libs:owncloud" -> ":core:ktx" [style=dotted]
":libs:owncloud" -> ":core:i18n" [style=dotted]
":libs:webdav" -> ":libs:webdav"
":libs:webdav" -> ":core:crashreporter" [style=dotted]
":libs:webdav" -> ":core:ktx" [style=dotted]
":services:accounts" -> ":services:accounts"
":services:accounts" -> ":libs:g-services" [style=dotted]
":services:accounts" -> ":libs:ms-services" [style=dotted]
":services:accounts" -> ":libs:owncloud" [style=dotted]
":services:accounts" -> ":libs:nextcloud" [style=dotted]
":services:backup" -> ":services:backup"
":services:backup" -> ":data:favorites" [style=dotted]
":services:backup" -> ":data:widgets" [style=dotted]
":services:backup" -> ":data:search-actions" [style=dotted]
":services:backup" -> ":core:preferences" [style=dotted]
":services:backup" -> ":core:ktx" [style=dotted]
":services:backup" -> ":data:customattrs" [style=dotted]
":data:contacts" -> ":data:contacts"
":data:contacts" -> ":core:ktx" [style=dotted]
":data:contacts" -> ":core:base" [style=dotted]
":data:contacts" -> ":core:permissions" [style=dotted]
":data:notifications" -> ":data:notifications"
":data:notifications" -> ":core:permissions" [style=dotted]
":data:applications" -> ":data:applications"
":data:applications" -> ":core:base" [style=dotted]
":data:applications" -> ":core:ktx" [style=dotted]
":data:applications" -> ":core:compat" [style=dotted]
":data:currencies" -> ":data:currencies"
":data:currencies" -> ":core:ktx" [style=dotted]
":data:currencies" -> ":core:i18n" [style=dotted]
":data:currencies" -> ":core:database" [style=dotted]
":data:currencies" -> ":core:crashreporter" [style=dotted]
":plugins:sdk" -> ":plugins:sdk"
":plugins:sdk" -> ":core:shared" [style=dotted]
":services:badges" -> ":services:badges"
":services:badges" -> ":core:ktx" [style=dotted]
":services:badges" -> ":data:applications" [style=dotted]
@ -275,11 +258,36 @@ digraph {
":services:badges" -> ":core:preferences" [style=dotted]
":services:badges" -> ":core:base" [style=dotted]
":services:badges" -> ":data:files" [style=dotted]
":services:global-actions" -> ":services:global-actions"
":services:global-actions" -> ":core:preferences" [style=dotted]
":services:global-actions" -> ":core:base" [style=dotted]
":services:global-actions" -> ":core:i18n" [style=dotted]
":services:global-actions" -> ":core:permissions" [style=dotted]
":services:favorites" -> ":services:favorites"
":services:favorites" -> ":core:base" [style=dotted]
":services:favorites" -> ":core:i18n" [style=dotted]
":services:favorites" -> ":data:searchable" [style=dotted]
":services:backup" -> ":services:backup"
":services:backup" -> ":data:searchable" [style=dotted]
":services:backup" -> ":data:widgets" [style=dotted]
":services:backup" -> ":data:search-actions" [style=dotted]
":services:backup" -> ":core:preferences" [style=dotted]
":services:backup" -> ":core:ktx" [style=dotted]
":services:backup" -> ":data:customattrs" [style=dotted]
":services:backup" -> ":data:themes" [style=dotted]
":services:search" -> ":services:search"
":services:search" -> ":data:calculator" [style=dotted]
":services:search" -> ":data:unitconverter" [style=dotted]
":services:search" -> ":data:customattrs" [style=dotted]
":services:search" -> ":data:search-actions" [style=dotted]
":services:search" -> ":core:base" [style=dotted]
":services:search" -> ":core:preferences" [style=dotted]
":services:search" -> ":core:crashreporter" [style=dotted]
":services:search" -> ":core:ktx" [style=dotted]
":services:music" -> ":services:music"
":services:music" -> ":core:ktx" [style=dotted]
":services:music" -> ":core:preferences" [style=dotted]
":services:music" -> ":data:notifications" [style=dotted]
":services:music" -> ":core:crashreporter" [style=dotted]
":services:accounts" -> ":services:accounts"
":services:accounts" -> ":libs:g-services" [style=dotted]
":services:accounts" -> ":libs:owncloud" [style=dotted]
":services:accounts" -> ":libs:nextcloud" [style=dotted]
":services:icons" -> ":data:customattrs"
":services:icons" -> ":services:icons"
":services:icons" -> ":core:database" [style=dotted]
@ -288,37 +296,37 @@ digraph {
":services:icons" -> ":core:base" [style=dotted]
":services:icons" -> ":data:applications" [style=dotted]
":services:icons" -> ":core:crashreporter" [style=dotted]
":services:music" -> ":services:music"
":services:music" -> ":core:ktx" [style=dotted]
":services:music" -> ":core:preferences" [style=dotted]
":services:music" -> ":data:notifications" [style=dotted]
":services:music" -> ":core:crashreporter" [style=dotted]
":services:search" -> ":services:search"
":services:search" -> ":data:applications" [style=dotted]
":services:search" -> ":data:appshortcuts" [style=dotted]
":services:search" -> ":data:calculator" [style=dotted]
":services:search" -> ":data:calendar" [style=dotted]
":services:search" -> ":data:contacts" [style=dotted]
":services:search" -> ":data:files" [style=dotted]
":services:search" -> ":data:unitconverter" [style=dotted]
":services:search" -> ":data:websites" [style=dotted]
":services:search" -> ":data:wikipedia" [style=dotted]
":services:search" -> ":data:customattrs" [style=dotted]
":services:search" -> ":data:search-actions" [style=dotted]
":services:search" -> ":core:base" [style=dotted]
":services:search" -> ":core:database" [style=dotted]
":services:search" -> ":core:preferences" [style=dotted]
":services:search" -> ":core:crashreporter" [style=dotted]
":services:search" -> ":core:ktx" [style=dotted]
":services:widgets" -> ":services:widgets"
":services:widgets" -> ":core:base" [style=dotted]
":services:widgets" -> ":core:i18n" [style=dotted]
":services:widgets" -> ":data:widgets" [style=dotted]
":services:global-actions" -> ":services:global-actions"
":services:global-actions" -> ":core:preferences" [style=dotted]
":services:global-actions" -> ":core:base" [style=dotted]
":services:global-actions" -> ":core:i18n" [style=dotted]
":services:global-actions" -> ":core:permissions" [style=dotted]
":services:tags" -> ":services:tags"
":services:tags" -> ":core:preferences" [style=dotted]
":services:tags" -> ":core:base" [style=dotted]
":services:tags" -> ":core:ktx" [style=dotted]
":services:tags" -> ":core:crashreporter" [style=dotted]
":services:tags" -> ":data:customattrs" [style=dotted]
":services:tags" -> ":data:favorites" [style=dotted]
":services:widgets" -> ":services:widgets"
":services:widgets" -> ":core:base" [style=dotted]
":services:widgets" -> ":core:i18n" [style=dotted]
":services:widgets" -> ":data:widgets" [style=dotted]
":services:tags" -> ":data:searchable" [style=dotted]
":libs:nextcloud" -> ":libs:webdav"
":libs:nextcloud" -> ":libs:nextcloud"
":libs:nextcloud" -> ":core:i18n" [style=dotted]
":libs:webdav" -> ":libs:webdav"
":libs:webdav" -> ":core:crashreporter" [style=dotted]
":libs:webdav" -> ":core:ktx" [style=dotted]
":libs:g-services" -> ":libs:g-services"
":libs:g-services" -> ":core:i18n" [style=dotted]
":libs:g-services" -> ":core:crashreporter" [style=dotted]
":libs:material-color-utilities" -> ":libs:material-color-utilities"
":libs:owncloud" -> ":libs:webdav"
":libs:owncloud" -> ":libs:owncloud"
":libs:owncloud" -> ":core:crashreporter" [style=dotted]
":libs:owncloud" -> ":core:ktx" [style=dotted]
":libs:owncloud" -> ":core:i18n" [style=dotted]
":libs:ms-services" -> ":libs:ms-services"
":libs:ms-services" -> ":core:crashreporter" [style=dotted]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@ -4,7 +4,7 @@ import android.content.Context
import android.net.Uri
import android.os.Build
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.searchable.SearchableRepository
import de.mm20.launcher2.searchable.SavableSearchableRepository
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.export
import de.mm20.launcher2.preferences.import
@ -22,7 +22,7 @@ import java.util.zip.ZipOutputStream
class BackupManager(
private val context: Context,
private val dataStore: LauncherDataStore,
private val searchableRepository: SearchableRepository,
private val searchableRepository: SavableSearchableRepository,
private val widgetRepository: WidgetRepository,
private val searchActionRepository: SearchActionRepository,
private val customAttrsRepository: CustomAttributesRepository,

View File

@ -4,10 +4,8 @@ import android.content.Context
import android.content.pm.PackageManager
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.graphics.BadgeDrawable
import de.mm20.launcher2.search.data.LauncherShortcut
import de.mm20.launcher2.search.data.LegacyShortcut
import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.data.UnavailableShortcut
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
@ -17,53 +15,37 @@ class AppShortcutBadgeProvider(
private val context: Context
) : BadgeProvider {
override fun getBadge(searchable: Searchable): Flow<Badge?> = channelFlow {
if (searchable is LauncherShortcut) {
val componentName = searchable.launcherShortcut.activity
if (componentName == null) {
if (searchable is AppShortcut) {
val componentName = searchable.componentName
val packageName = searchable.packageName
if (componentName != null) {
withContext(Dispatchers.IO) {
val icon = try {
context.packageManager.getActivityIcon(
componentName
)
} catch (e: PackageManager.NameNotFoundException) {
return@withContext
}
val badge = Badge(icon = BadgeDrawable(context, icon))
send(badge)
}
} else if (packageName != null) {
withContext(Dispatchers.IO) {
val icon = try {
context.packageManager.getApplicationIcon(
packageName
)
} catch (e: PackageManager.NameNotFoundException) {
return@withContext
}
val badge = Badge(icon = BadgeDrawable(context, icon))
send(badge)
}
} else {
send(null)
return@channelFlow
}
withContext(Dispatchers.IO) {
val icon = try {
context.packageManager.getActivityIcon(
componentName
)
} catch (e: PackageManager.NameNotFoundException) {
return@withContext
}
val badge = Badge(icon = BadgeDrawable(context, icon))
send(badge)
}
} else if (searchable is LegacyShortcut) {
val packageName = searchable.packageName
if (packageName == null) {
send(null)
return@channelFlow
}
withContext(Dispatchers.IO) {
val icon = try {
context.packageManager.getApplicationIcon(
packageName
)
} catch (e: PackageManager.NameNotFoundException) {
return@withContext
}
val badge = Badge(icon = BadgeDrawable(context, icon))
send(badge)
}
} else if (searchable is UnavailableShortcut) {
val packageName = searchable.packageName
withContext(Dispatchers.IO) {
val icon = try {
context.packageManager.getApplicationIcon(
packageName
)
} catch (e: PackageManager.NameNotFoundException) {
return@withContext
}
val badge = Badge(icon = BadgeDrawable(context, icon))
send(badge)
}
} else {
send(null)
}

View File

@ -1,7 +1,7 @@
package de.mm20.launcher2.badges.providers
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.Searchable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

View File

@ -3,7 +3,7 @@ package de.mm20.launcher2.badges.providers
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.notifications.NotificationRepository
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.Application
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
@ -15,8 +15,8 @@ class NotificationBadgeProvider : BadgeProvider, KoinComponent {
private val notificationRepository: NotificationRepository by inject()
override fun getBadge(searchable: Searchable): Flow<Badge?> = channelFlow {
if (searchable is LauncherApp) {
val packageName = searchable.`package`
if (searchable is Application) {
val packageName = searchable.componentName.packageName
notificationRepository.notifications.map {
it.filter { it.packageName == packageName }
}.collectLatest {

View File

@ -1,33 +1,18 @@
package de.mm20.launcher2.badges.providers
import de.mm20.launcher2.applications.AppRepository
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.badges.R
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.data.LauncherApp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class SuspendedAppsBadgeProvider : BadgeProvider, KoinComponent {
private val appRepository: AppRepository by inject()
override fun getBadge(searchable: Searchable): Flow<Badge?> = channelFlow {
if (searchable is LauncherApp) {
val packageName = searchable.`package`
appRepository.getSuspendedPackages().collectLatest {
if (it.contains(packageName)) {
send(
Badge(
iconRes = R.drawable.ic_badge_suspended
)
)
} else {
send(null)
}
}
if (searchable is Application && searchable.isSuspended) {
send(Badge(iconRes = R.drawable.ic_badge_suspended))
} else {
send(null)
}

View File

@ -2,15 +2,16 @@ package de.mm20.launcher2.badges.providers
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.badges.R
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.data.LauncherShortcut
import de.mm20.launcher2.search.AppProfile
import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.search.Searchable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class WorkProfileBadgeProvider : BadgeProvider {
override fun getBadge(searchable: Searchable): Flow<Badge?> = flow {
if (searchable is LauncherApp && !searchable.isMainProfile || searchable is LauncherShortcut && !searchable.isMainProfile) {
if (searchable is Application && searchable.profile == AppProfile.Work || searchable is AppShortcut && searchable.profile == AppProfile.Work) {
emit(
Badge(
iconRes = R.drawable.ic_badge_workprofile

View File

@ -1,11 +1,11 @@
package de.mm20.launcher2.services.favorites
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.searchable.SearchableRepository
import de.mm20.launcher2.searchable.SavableSearchableRepository
import kotlinx.coroutines.flow.Flow
class FavoritesService(
val searchableRepository: SearchableRepository,
val searchableRepository: SavableSearchableRepository,
) {

Some files were not shown because too many files have changed in this diff Show More