Refactor widgets

This commit is contained in:
MM20 2023-04-10 01:15:27 +02:00
parent 2054386179
commit 3cda75bc77
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
37 changed files with 1153 additions and 347 deletions

View File

@ -156,6 +156,7 @@ dependencies {
implementation(project(":core:database"))
implementation(project(":data:search-actions"))
implementation(project(":services:global-actions"))
implementation(project(":services:widgets"))
// Uncomment this if you want annoying notifications in your debug builds yelling at you how terrible your code is
//debugImplementation(libs.leakcanary)

View File

@ -30,6 +30,7 @@ import de.mm20.launcher2.permissions.permissionsModule
import de.mm20.launcher2.preferences.preferencesModule
import de.mm20.launcher2.searchactions.searchActionsModule
import de.mm20.launcher2.services.tags.servicesTagsModule
import de.mm20.launcher2.services.widgets.widgetsServiceModule
import de.mm20.launcher2.weather.weatherModule
import kotlinx.coroutines.*
import org.koin.android.ext.koin.androidContext
@ -81,6 +82,7 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory {
widgetsModule,
wikipediaModule,
servicesTagsModule,
widgetsServiceModule,
)
)
}

View File

@ -150,4 +150,5 @@ dependencies {
implementation(project(":services:backup"))
implementation(project(":data:search-actions"))
implementation(project(":services:global-actions"))
implementation(project(":services:widgets"))
}

View File

@ -8,6 +8,7 @@ import de.mm20.launcher2.ui.component.ProvideIconShape
import de.mm20.launcher2.ui.locals.LocalCardStyle
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.widgets.FavoritesWidget
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
@ -35,7 +36,7 @@ fun ProvideSettings(
val favoritesEnabled by remember {
combine(
widgetRepository.isFavoritesWidgetEnabled(),
widgetRepository.exists(FavoritesWidget.Type),
dataStore.data.map { it.favorites.enabled },
dataStore.data.map { it.clockWidget.favoritesPart },
) { a, b, c -> a || b || c }.distinctUntilChanged()

View File

@ -8,6 +8,7 @@ import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.Tag
import de.mm20.launcher2.widgets.CalendarWidget
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.flow.*
import org.koin.core.component.KoinComponent
@ -36,7 +37,7 @@ abstract class FavoritesVM : ViewModel(), KoinComponent {
open val favorites: Flow<List<SavableSearchable>> = selectedTag.flatMapLatest { tag ->
if (tag == null) {
val columns = dataStore.data.map { it.grid.columnCount }
val excludeCalendar = widgetRepository.isCalendarWidgetEnabled()
val excludeCalendar = widgetRepository.exists(CalendarWidget.Type)
val includeFrequentlyUsed = dataStore.data.map { it.favorites.frequentlyUsed }
val frequentlyUsedRows = dataStore.data.map { it.favorites.frequentlyUsedRows }

View File

@ -61,11 +61,13 @@ import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.launcher.widgets.picker.PickAppWidgetActivity
import de.mm20.launcher2.widgets.CalendarWidget
import de.mm20.launcher2.widgets.ExternalWidget
import de.mm20.launcher2.widgets.AppWidget
import de.mm20.launcher2.widgets.AppWidgetConfig
import de.mm20.launcher2.widgets.FavoritesWidget
import de.mm20.launcher2.widgets.MusicWidget
import de.mm20.launcher2.widgets.WeatherWidget
import de.mm20.launcher2.widgets.Widget
import java.util.UUID
import kotlin.math.roundToInt
class BindAndConfigureAppWidgetActivity : Activity() {
@ -191,9 +193,12 @@ private class BindAndConfigureAppWidgetContract(
)
if (widgetId != null && widgetProviderInfo != null) {
return ExternalWidget(
height = widgetProviderInfo.minHeight,
widgetId = widgetId,
return AppWidget(
id = UUID.randomUUID(),
config = AppWidgetConfig(
height = widgetProviderInfo.minHeight,
widgetId = widgetId,
),
widgetProviderInfo = widgetProviderInfo,
)
}
@ -277,7 +282,15 @@ fun WidgetPickerSheet(
.fillMaxWidth()
.padding(bottom = 16.dp, start = 16.dp, end = 16.dp),
onClick = {
viewModel.pickWidget(it)
val id = UUID.randomUUID()
val widget = when(it.type) {
WeatherWidget.Type -> WeatherWidget(id)
CalendarWidget.Type -> CalendarWidget(id)
MusicWidget.Type -> MusicWidget(id)
FavoritesWidget.Type -> FavoritesWidget(id)
else -> return@OutlinedCard
}
viewModel.pickWidget(widget)
onDismiss()
}) {
Row(
@ -285,18 +298,18 @@ fun WidgetPickerSheet(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = when (it) {
is WeatherWidget -> Icons.Rounded.LightMode
is CalendarWidget -> Icons.Rounded.Today
is MusicWidget -> Icons.Rounded.MusicNote
is FavoritesWidget -> Icons.Rounded.Star
imageVector = when (it.type) {
WeatherWidget.Type -> Icons.Rounded.LightMode
CalendarWidget.Type -> Icons.Rounded.Today
MusicWidget.Type -> Icons.Rounded.MusicNote
FavoritesWidget.Type -> Icons.Rounded.Star
else -> Icons.Rounded.Widgets
},
contentDescription = null,
modifier = Modifier.padding(end = 16.dp)
)
Text(
text = it.loadLabel(context),
text = it.label,
style = MaterialTheme.typography.titleSmall
)
}

View File

@ -1,14 +1,21 @@
package de.mm20.launcher2.ui.launcher.sheets
import WidgetsService
import android.appwidget.AppWidgetProviderInfo
import android.content.Context
import android.content.pm.PackageManager
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Star
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import de.mm20.launcher2.ktx.normalize
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.widgets.FavoritesWidget
import de.mm20.launcher2.widgets.Widget
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.Dispatchers
@ -24,7 +31,7 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class WidgetPickerSheetVM(
private val widgetRepository: WidgetRepository,
private val widgetsService: WidgetsService,
private val context: Context,
) : ViewModel() {
@ -32,11 +39,11 @@ class WidgetPickerSheetVM(
val searchQuery = MutableStateFlow("")
private val enabledWidgets = widgetRepository.getWidgets()
private val enabledWidgets = widgetsService.getWidgets()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(100), emptyList())
private val allBuiltInWidgets = enabledWidgets.map { w ->
widgetRepository.getInternalWidgets().filter { !w.contains(it) }
widgetsService.getBuiltInWidgets().filter { b -> !w.any { it::class == b::class } }
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(100))
val builtInWidgets = allBuiltInWidgets
@ -45,13 +52,13 @@ class WidgetPickerSheetVM(
withContext(Dispatchers.IO) {
val normalizedQuery = query.normalize()
widgets.filter {
it.loadLabel(context).normalize().contains(normalizedQuery)
it.label.normalize().contains(normalizedQuery)
}
}
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(100))
private val allAppWidgets = flow {
val widgets = widgetRepository.getAppWidgets()
val widgets = widgetsService.getAppWidgetProviders()
emit(widgets)
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(100))
@ -105,7 +112,7 @@ class WidgetPickerSheetVM(
fun pickWidget(widget: Widget) {
val position = enabledWidgets.value.size
widgetRepository.addWidget(widget, position)
widgetsService.addWidget(widget, position)
}
fun toggleGroup(group: String) {
@ -123,10 +130,17 @@ class WidgetPickerSheetVM(
}
}
}
}
data class AppWidgetGroup(
val appName: String,
val packageName: String,
val widgets: List<AppWidgetProviderInfo>
)
data class BuiltInWidgetInfo(
val type: String,
@StringRes val label: Int,
val icon: ImageVector
)

View File

@ -3,32 +3,19 @@ package de.mm20.launcher2.ui.launcher.widgets
import android.app.Activity
import android.appwidget.AppWidgetHost
import android.appwidget.AppWidgetManager
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -37,7 +24,6 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onPlaced
@ -45,19 +31,15 @@ import androidx.compose.ui.layout.positionInParent
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.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.ktx.animateTo
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget
import de.mm20.launcher2.ui.launcher.widgets.picker.PickAppWidgetActivity
import de.mm20.launcher2.widgets.ExternalWidget
import de.mm20.launcher2.widgets.AppWidget
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
@ -85,19 +67,6 @@ fun WidgetColumn(
}
}
val pickWidgetLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {
val data = it.data ?: return@rememberLauncherForActivityResult
val widgetId = data.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
)
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@rememberLauncherForActivityResult
if (it.resultCode == Activity.RESULT_OK) {
viewModel.addAppWidget(context, widgetId)
}
}
Column(
modifier = modifier
) {
@ -109,7 +78,7 @@ fun WidgetColumn(
}
val widgetsWithIndex = remember(widgets) { widgets.withIndex() }
for ((i, widget) in widgetsWithIndex) {
key(if (widget is ExternalWidget) widget.widgetId else widget) {
key(widget.id) {
var dragOffsetAfterSwap = remember<Float?> { null }
val offsetY = remember(widgets) { mutableStateOf(dragOffsetAfterSwap ?: 0f) }
@ -122,13 +91,13 @@ fun WidgetColumn(
appWidgetHost = widgetHost,
editMode = editMode,
onWidgetRemove = {
if (widget is ExternalWidget) {
widgetHost.deleteAppWidgetId(widget.widgetId)
if (widget is AppWidget) {
widgetHost.deleteAppWidgetId(widget.config.widgetId)
}
viewModel.removeWidget(widget)
},
onWidgetResize = {
viewModel.setWidgetHeight(widget, it)
onWidgetUpdate = {
viewModel.updateWidget(it)
},
modifier = Modifier
.fillMaxWidth()

View File

@ -2,6 +2,7 @@ package de.mm20.launcher2.ui.launcher.widgets
import android.app.Activity
import android.appwidget.AppWidgetHost
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.gestures.*
@ -29,7 +30,6 @@ import de.mm20.launcher2.ui.launcher.widgets.favorites.FavoritesWidget
import de.mm20.launcher2.ui.launcher.widgets.music.MusicWidget
import de.mm20.launcher2.ui.launcher.widgets.weather.WeatherWidget
import de.mm20.launcher2.widgets.*
import java.lang.Integer.max
import kotlin.math.roundToInt
@Composable
@ -38,7 +38,7 @@ fun WidgetItem(
appWidgetHost: AppWidgetHost,
modifier: Modifier = Modifier,
editMode: Boolean = false,
onWidgetResize: (newHeight: Int) -> Unit = {},
onWidgetUpdate: (widget: Widget) -> Unit = {},
onWidgetRemove: () -> Unit = {},
draggableState: DraggableState = rememberDraggableState {},
onDragStopped: () -> Unit = {}
@ -84,7 +84,7 @@ fun WidgetItem(
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
if (widget is ExternalWidget) {
if (widget is AppWidget) {
IconButton(onClick = { resizeMode = !resizeMode }) {
Icon(
imageVector = Icons.Rounded.Edit,
@ -124,19 +124,19 @@ fun WidgetItem(
is FavoritesWidget -> {
FavoritesWidget()
}
is ExternalWidget -> {
var height by remember(widget) { mutableStateOf(widget.height) }
is AppWidget -> {
var dragDelta by remember { mutableStateOf(0) }
Column {
ExternalWidget(
appWidgetHost = appWidgetHost,
widgetId = widget.widgetId,
widgetId = widget.config.widgetId,
modifier = Modifier.fillMaxWidth(),
height = height,
height = widget.config.height + dragDelta,
)
if (resizeMode) {
val density = LocalDensity.current
val drgStt = rememberDraggableState {
height += (it / density.density).roundToInt()
dragDelta += (it / density.density).roundToInt()
}
Icon(
imageVector = Icons.Rounded.DragHandle,
@ -150,7 +150,12 @@ fun WidgetItem(
orientation = Orientation.Vertical,
startDragImmediately = true,
onDragStopped = {
onWidgetResize(height)
onWidgetUpdate(widget.copy(
config = widget.config.copy(
height = widget.config.height + dragDelta
)
))
dragDelta = 0
}
)
)

View File

@ -1,11 +1,9 @@
package de.mm20.launcher2.ui.launcher.widgets
import android.appwidget.AppWidgetManager
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.widgets.ExternalWidget
import de.mm20.launcher2.widgets.Widget
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.flow.map
@ -19,49 +17,28 @@ class WidgetsVM : ViewModel(), KoinComponent {
val editButton = dataStore.data.map { it.widgets.editButton }.asLiveData()
val widgets = widgetRepository.getWidgets().asLiveData()
val widgets = widgetRepository.get().asLiveData()
fun addWidget(widget: Widget) {
widgetRepository.addWidget(widget, widgets.value?.size ?: 0)
}
fun addAppWidget(context: Context, widgetId: Int) {
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return
val appWidget = AppWidgetManager.getInstance(context)
.getAppWidgetInfo(widgetId) ?: return
val widget = ExternalWidget(
widgetProviderInfo = appWidget,
height = appWidget.minHeight,
widgetId = widgetId,
)
addWidget(widget)
}
fun removeWidget(widget: Widget) {
widgetRepository.removeWidget(widget)
widgetRepository.delete(widget)
}
fun setWidgetHeight(widget: Widget, newHeight: Int) {
widgetRepository.setWidgetHeight(widget, newHeight)
}
fun getAvailableBuiltInWidgets(): List<Widget> {
return widgetRepository.getInternalWidgets().filter {
widgets.value?.contains(it)?.not() ?: false
}
fun updateWidget(widget: Widget) {
widgetRepository.update(widget)
}
fun moveUp(index: Int) {
val widgets = widgets.value?.toMutableList() ?: return
val widget = widgets.removeAt(index)
widgets.add(index - 1, widget)
widgetRepository.saveWidgets(widgets)
widgetRepository.set(widgets)
}
fun moveDown(index: Int) {
val widgets = widgets.value?.toMutableList() ?: return
val widget = widgets.removeAt(index)
widgets.add(index + 1, widget)
widgetRepository.saveWidgets(widgets)
widgetRepository.set(widgets)
}
}

View File

@ -16,6 +16,7 @@ import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings.ClockWidgetSettings.ClockWidgetLayout
import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid
import de.mm20.launcher2.widgets.CalendarWidget
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@ -41,7 +42,7 @@ class FavoritesPartProvider : PartProvider, KoinComponent {
if (layout == ClockWidgetLayout.Horizontal) c - 2 else c
}
}.collectAsState(0)
val excludeCalendar by remember { widgetRepository.isCalendarWidgetEnabled() }.collectAsState(
val excludeCalendar by remember { widgetRepository.exists(CalendarWidget.Type) }.collectAsState(
true
)

View File

@ -1,5 +1,6 @@
package de.mm20.launcher2.ui.launcher.widgets.favorites
import WidgetsService
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.preferences.Settings.ClockWidgetSettings.ClockWidgetLayout
import de.mm20.launcher2.ui.common.FavoritesVM
@ -9,13 +10,16 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import org.koin.core.component.inject
class FavoritesWidgetVM : FavoritesVM() {
private val widgetsService: WidgetsService by inject()
override val tagsExpanded: Flow<Boolean> = dataStore.data.map { it.ui.widgetTagsMultiline }
.shareIn(viewModelScope, SharingStarted.Lazily)
private val isTopWidget = widgetRepository.isFavoritesWidgetFirst()
private val isTopWidget = widgetsService.isFavoritesWidgetFirst()
private val clockWidgetFavSlots = dataStore.data.combine(isTopWidget) { data, isTop ->
if (!isTop || !data.clockWidget.favoritesPart) 0
else {

View File

@ -4,6 +4,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.widgets.CalendarWidget
import de.mm20.launcher2.widgets.FavoritesWidget
import de.mm20.launcher2.widgets.MusicWidget
import de.mm20.launcher2.widgets.WeatherWidget
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@ -16,10 +20,10 @@ class WidgetSettingsScreenVM : ViewModel(), KoinComponent {
private val widgetRepository: WidgetRepository by inject()
private val dataStore: LauncherDataStore by inject()
val calendarWidget = widgetRepository.isCalendarWidgetEnabled().asLiveData()
val musicWidget = widgetRepository.isMusicWidgetEnabled().asLiveData()
val weatherWidget = widgetRepository.isWeatherWidgetEnabled().asLiveData()
val favoritesWidget = widgetRepository.isFavoritesWidgetEnabled().asLiveData()
val calendarWidget = widgetRepository.exists(CalendarWidget.Type).asLiveData()
val musicWidget = widgetRepository.exists(MusicWidget.Type).asLiveData()
val weatherWidget = widgetRepository.exists(WeatherWidget.Type).asLiveData()
val favoritesWidget = widgetRepository.exists(FavoritesWidget.Type).asLiveData()
val editButton = dataStore.data.map { it.widgets.editButton }.asLiveData()
fun setEditButton(editButton: Boolean) {
viewModelScope.launch {

View File

@ -1,4 +1,8 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
kotlin("plugin.serialization") version libs.versions.kotlin apply false
id("org.jetbrains.kotlin.android") version libs.versions.kotlin apply false
}
buildscript {
repositories {

View File

@ -0,0 +1,493 @@
{
"formatVersion": 1,
"database": {
"version": 23,
"identityHash": "cb1e4fa1db90a1afddc085714570430d",
"entities": [
{
"tableName": "forecasts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `temperature` REAL NOT NULL, `minTemp` REAL NOT NULL, `maxTemp` REAL NOT NULL, `pressure` REAL NOT NULL, `humidity` REAL NOT NULL, `icon` INTEGER NOT NULL, `condition` TEXT NOT NULL, `clouds` INTEGER NOT NULL, `windSpeed` REAL NOT NULL, `windDirection` REAL NOT NULL, `rain` REAL NOT NULL, `snow` REAL NOT NULL, `night` INTEGER NOT NULL, `location` TEXT NOT NULL, `provider` TEXT NOT NULL, `providerUrl` TEXT NOT NULL, `rainProbability` INTEGER NOT NULL, `snowProbability` INTEGER NOT NULL, `updateTime` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))",
"fields": [
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "temperature",
"columnName": "temperature",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "minTemp",
"columnName": "minTemp",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "maxTemp",
"columnName": "maxTemp",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "pressure",
"columnName": "pressure",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "humidity",
"columnName": "humidity",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "condition",
"columnName": "condition",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "clouds",
"columnName": "clouds",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "windSpeed",
"columnName": "windSpeed",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "windDirection",
"columnName": "windDirection",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "precipitation",
"columnName": "rain",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "snow",
"columnName": "snow",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "night",
"columnName": "night",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "provider",
"columnName": "provider",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "providerUrl",
"columnName": "providerUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "precipProbability",
"columnName": "rainProbability",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "snowProbability",
"columnName": "snowProbability",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"timestamp"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Searchable",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `type` TEXT NOT NULL, `searchable` TEXT NOT NULL, `launchCount` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `hidden` INTEGER NOT NULL, `weight` REAL NOT NULL DEFAULT 0.0, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serializedSearchable",
"columnName": "searchable",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinPosition",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hidden",
"columnName": "hidden",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weight",
"columnName": "weight",
"affinity": "REAL",
"notNull": true,
"defaultValue": "0.0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Currency",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`symbol` TEXT NOT NULL, `value` REAL NOT NULL, `lastUpdate` INTEGER NOT NULL, PRIMARY KEY(`symbol`))",
"fields": [
{
"fieldPath": "symbol",
"columnName": "symbol",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lastUpdate",
"columnName": "lastUpdate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"symbol"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Icons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `packageName` TEXT, `activityName` TEXT, `drawable` TEXT, `extras` TEXT, `iconPack` TEXT NOT NULL, `name` TEXT, `themed` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "activityName",
"columnName": "activityName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "drawable",
"columnName": "drawable",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "extras",
"columnName": "extras",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "iconPack",
"columnName": "iconPack",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "themed",
"columnName": "themed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "IconPack",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `packageName` TEXT NOT NULL, `version` TEXT NOT NULL, `scale` REAL NOT NULL, `themed` INTEGER NOT NULL, PRIMARY KEY(`packageName`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "scale",
"columnName": "scale",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "themed",
"columnName": "themed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"packageName"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Widget",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `config` TEXT, `position` INTEGER NOT NULL, `id` BLOB NOT NULL, `parentId` BLOB, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "config",
"columnName": "config",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "parentId",
"columnName": "parentId",
"affinity": "BLOB",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "CustomAttributes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT)",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "SearchAction",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `data` TEXT, `label` TEXT, `icon` INTEGER, `color` INTEGER, `customIcon` TEXT, `options` TEXT, PRIMARY KEY(`position`))",
"fields": [
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "data",
"columnName": "data",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "customIcon",
"columnName": "customIcon",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "options",
"columnName": "options",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"position"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cb1e4fa1db90a1afddc085714570430d')"
]
}
}

View File

@ -21,10 +21,13 @@ import de.mm20.launcher2.database.migrations.Migration_18_19
import de.mm20.launcher2.database.migrations.Migration_19_20
import de.mm20.launcher2.database.migrations.Migration_20_21
import de.mm20.launcher2.database.migrations.Migration_21_22
import de.mm20.launcher2.database.migrations.Migration_22_23
import de.mm20.launcher2.database.migrations.Migration_6_7
import de.mm20.launcher2.database.migrations.Migration_7_8
import de.mm20.launcher2.database.migrations.Migration_8_9
import de.mm20.launcher2.database.migrations.Migration_9_10
import de.mm20.launcher2.ktx.toBytes
import java.util.UUID
@Database(
entities = [
@ -36,7 +39,7 @@ import de.mm20.launcher2.database.migrations.Migration_9_10
WidgetEntity::class,
CustomAttributeEntity::class,
SearchActionEntity::class
], version = 22, exportSchema = true
], version = 23, exportSchema = true
)
@TypeConverters(ComponentNameConverter::class, StringListConverter::class)
abstract class AppDatabase : RoomDatabase() {
@ -81,10 +84,15 @@ abstract class AppDatabase : RoomDatabase() {
)
db.execSQL(
"INSERT INTO Widget (type, data, height, position, label) VALUES " +
"('internal', 'weather', -1, 0, '${context.getString(R.string.widget_name_weather)}')," +
"('internal', 'music', -1, 1, '${context.getString(R.string.widget_name_music)}')," +
"('internal', 'calendar', -1, 2, '${context.getString(R.string.widget_name_calendar)}');"
"INSERT INTO Widget (`type`, `position`, `id`) VALUES " +
"('weather', 0, ?)," +
"('music', 1, ?)," +
"('calendar', 2, ?);",
arrayOf(
UUID.randomUUID().toBytes(),
UUID.randomUUID().toBytes(),
UUID.randomUUID().toBytes()
)
)
}
})
@ -104,7 +112,8 @@ abstract class AppDatabase : RoomDatabase() {
Migration_18_19(),
Migration_19_20(),
Migration_20_21(),
Migration_21_22()
Migration_21_22(),
Migration_22_23(),
).build()
if (_instance == null) _instance = instance
return instance

View File

@ -1,42 +1,54 @@
package de.mm20.launcher2.database
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import de.mm20.launcher2.database.entities.WidgetEntity
import de.mm20.launcher2.database.entities.PartialWidgetEntity
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Dao
interface WidgetDao {
@Query("SELECT * FROM Widget ORDER BY position ASC")
fun getWidgets(): Flow<List<WidgetEntity>>
@Query("SELECT * FROM Widget WHERE parentId IS NULL ORDER BY position ASC LIMIT :limit OFFSET :offset")
fun queryRoot(limit: Int, offset: Int): Flow<List<WidgetEntity>>
@Transaction
fun updateWidgets(widgets: List<WidgetEntity>) {
deleteAll()
insertAll(widgets)
}
@Query("SELECT * FROM Widget WHERE parentId = :parentId ORDER BY position ASC LIMIT :limit OFFSET :offset")
fun queryByParent(parentId: UUID,limit: Int, offset: Int): Flow<List<WidgetEntity>>
@Insert
fun insertAll(widgets: List<WidgetEntity>)
suspend fun insert(widget: WidgetEntity)
@Insert
fun insert(widget: WidgetEntity)
suspend fun insert(widgets: List<WidgetEntity>)
@Query("DELETE FROM Widget")
fun deleteAll()
@Update(entity = WidgetEntity::class)
suspend fun patch(widget: PartialWidgetEntity)
@Update(entity = WidgetEntity::class)
suspend fun patch(widgets: List<PartialWidgetEntity>)
@Query("DELETE FROM Widget WHERE data = :data AND type = :type")
fun deleteWidget(type: String, data: String)
@Update
suspend fun update(widget: WidgetEntity)
@Query("UPDATE Widget SET height = :newHeight WHERE data = :data AND type = :type")
fun updateHeight(type: String, data: String, newHeight: Int)
@Update
suspend fun update(widgets: List<WidgetEntity>)
@Query("SELECT EXISTS(SELECT 1 FROM Widget WHERE type = :type AND data = :data)")
fun exists(type: String, data: String) : Flow<Boolean>
@Query("DELETE FROM Widget WHERE id = :id")
suspend fun delete(id: UUID)
@Query("DELETE FROM WIDGET WHERE id IN (:ids)")
suspend fun delete(ids: List<UUID>)
@Query("DELETE FROM Widget WHERE parentId = :parentId")
suspend fun deleteByParent(parentId: UUID)
@Query("DELETE FROM Widget WHERE parentId IS NULL")
suspend fun deleteRoot()
@Query("SELECT EXISTS(SELECT 1 FROM Widget WHERE type = :type)")
fun exists(type: String): Flow<Boolean>
@Query("SELECT * FROM Widget ORDER BY position ASC LIMIT 1")
fun getFirst() : Flow<WidgetEntity?>
}

View File

@ -2,14 +2,23 @@ package de.mm20.launcher2.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(tableName = "Widget")
data class WidgetEntity(
val type: String,
var data: String,
var height: Int,
var config: String?,
var position: Int,
val label: String = "",
@PrimaryKey(autoGenerate = true) val id: Int? = null
@PrimaryKey val id: UUID,
val parentId: UUID? = null,
)
/**
* Partial entity for updating and deleting
*/
data class PartialWidgetEntity(
val type: String,
var config: String?,
@PrimaryKey val id: UUID,
)

View File

@ -0,0 +1,44 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import de.mm20.launcher2.ktx.toBytes
import org.koin.core.component.KoinComponent
import java.util.UUID
class Migration_22_23 : Migration(22, 23), KoinComponent {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE Widget RENAME TO Widget_old")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `Widget` (
`type` TEXT NOT NULL,
`config` TEXT,
`position` INTEGER NOT NULL,
`id` BLOB NOT NULL,
`parentId` BLOB,
PRIMARY KEY(`id`)
)
"""
)
val oldWidgets =
database.query("SELECT `type`, `data`, `height`, `position` FROM `Widget_old`")
while (oldWidgets.moveToNext()) {
val oldType = oldWidgets.getString(0)
val data = oldWidgets.getString(1)
val newType = if (oldType == "3rdparty") "app" else data
val height = oldWidgets.getInt(2)
val position = oldWidgets.getInt(3)
val id = UUID.randomUUID()
val config = if (oldType == "3rdparty") {
"{\"widgetId\": $data, \"height\": $height}"
} else null
database.execSQL(
"INSERT INTO `Widget` (`type`, `config`, `position`, `id`) VALUES (?, ?, ?, ?)",
arrayOf(newType, config, position, id.toBytes())
)
}
oldWidgets.close()
database.execSQL("DROP TABLE Widget_old")
}
}

View File

@ -0,0 +1,12 @@
package de.mm20.launcher2.ktx
import java.nio.ByteBuffer
import java.util.UUID
fun UUID.toBytes(): ByteArray {
val bytes = ByteArray(16)
val buffer = ByteBuffer.wrap(bytes)
buffer.putLong(mostSignificantBits)
buffer.putLong(leastSignificantBits)
return buffer.array()
}

View File

@ -1,6 +1,7 @@
plugins {
id("com.android.library")
id("kotlin-android")
kotlin("plugin.serialization")
}
android {
@ -42,6 +43,8 @@ dependencies {
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.kotlinx.serialization.json)
implementation(libs.koin.android)
implementation(project(":data:weather"))

View File

@ -0,0 +1,56 @@
package de.mm20.launcher2.widgets
import android.app.Activity
import android.appwidget.AppWidgetHost
import android.appwidget.AppWidgetProviderInfo
import android.content.Context
import android.os.Build
import de.mm20.launcher2.database.entities.PartialWidgetEntity
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
@Serializable
data class AppWidgetConfig(
val widgetId: Int,
val height: Int,
)
data class AppWidget(
override val id: UUID,
val config: AppWidgetConfig,
val widgetProviderInfo: AppWidgetProviderInfo
) : Widget() {
override fun loadLabel(context: Context): String {
return widgetProviderInfo.loadLabel(context.packageManager)
}
override fun toDatabaseEntity(): PartialWidgetEntity {
return PartialWidgetEntity(
id = id,
type = Type,
config = Json.encodeToString(config),
)
}
override val isConfigurable: Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
widgetProviderInfo.widgetFeatures and AppWidgetProviderInfo.WIDGET_FEATURE_RECONFIGURABLE != 0
} else {
false
}
override fun configure(context: Activity, appWidgetHost: AppWidgetHost) {
appWidgetHost.startAppWidgetConfigureActivityForResult(
context,
config.widgetId,
0,
0,
null
)
}
companion object {
const val Type = "app"
}
}

View File

@ -0,0 +1,52 @@
package de.mm20.launcher2.widgets
import android.app.Activity
import android.appwidget.AppWidgetHost
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import de.mm20.launcher2.database.entities.PartialWidgetEntity
import de.mm20.launcher2.ktx.tryStartActivity
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class CalendarWidgetConfig(
val allDayEvents: Boolean = true,
val excludedCalendarIds: List<Long> = emptyList(),
)
data class CalendarWidget(
override val id: UUID,
val config: CalendarWidgetConfig = CalendarWidgetConfig(),
) : Widget() {
override fun loadLabel(context: Context): String {
return context.getString(R.string.widget_name_calendar)
}
override fun toDatabaseEntity(): PartialWidgetEntity {
return PartialWidgetEntity(
id = id,
type = Type,
config = null,
)
}
override val isConfigurable: Boolean = true
override fun configure(context: Activity, appWidgetHost: AppWidgetHost) {
val intent = Intent()
intent.component = ComponentName(
context.getPackageName(),
"de.mm20.launcher2.ui.settings.SettingsActivity"
)
intent.putExtra(
"de.mm20.launcher2.settings.ROUTE",
"settings/widgets/calendar"
)
context.tryStartActivity(intent)
}
companion object {
const val Type = "calendar"
}
}

View File

@ -0,0 +1,55 @@
package de.mm20.launcher2.widgets
import android.app.Activity
import android.appwidget.AppWidgetHost
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import de.mm20.launcher2.database.entities.PartialWidgetEntity
import de.mm20.launcher2.ktx.tryStartActivity
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
@Serializable
data class WeatherWidgetConfig(
val showForecast: Boolean = true,
)
data class WeatherWidget(
override val id: UUID,
val config: WeatherWidgetConfig = WeatherWidgetConfig(),
) : Widget() {
override fun loadLabel(context: Context): String {
return context.getString(R.string.widget_name_weather)
}
override fun toDatabaseEntity(): PartialWidgetEntity {
return PartialWidgetEntity(
id = id,
type = Type,
config = Json.encodeToString(config),
)
}
override val isConfigurable: Boolean = true
override fun configure(context: Activity, appWidgetHost: AppWidgetHost) {
val intent = Intent()
intent.component = ComponentName(
context.getPackageName(),
"de.mm20.launcher2.ui.settings.SettingsActivity"
)
intent.putExtra(
"de.mm20.launcher2.settings.ROUTE",
"settings/widgets/weather"
)
context.tryStartActivity(intent)
}
companion object {
const val Type = "weather"
}
}

View File

@ -3,86 +3,78 @@ package de.mm20.launcher2.widgets
import android.app.Activity
import android.appwidget.AppWidgetHost
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import de.mm20.launcher2.database.entities.WidgetEntity
import de.mm20.launcher2.database.entities.PartialWidgetEntity
import de.mm20.launcher2.ktx.tryStartActivity
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import java.util.UUID
sealed class Widget {
abstract val id: UUID
abstract fun loadLabel(context: Context): String
abstract fun toDatabaseEntity(position: Int = -1): WidgetEntity
fun toDatabaseEntity(position: Int, parentId: UUID? = null): WidgetEntity {
return toDatabaseEntity().let {
WidgetEntity(
id = it.id,
type = it.type,
config = it.config,
position = position,
parentId = parentId,
)
}
}
abstract fun toDatabaseEntity(): PartialWidgetEntity
open val isConfigurable: Boolean = false
open fun configure(context: Activity, appWidgetHost: AppWidgetHost) {}
companion object {
fun fromDatabaseEntity(context: Context, entity: WidgetEntity): Widget? {
if (entity.type == WidgetType.INTERNAL.value) {
return when (entity.data) {
"weather" -> WeatherWidget
"music" -> MusicWidget
"calendar" -> CalendarWidget
"favorites" -> FavoritesWidget
else -> null
return when (entity.type) {
WeatherWidget.Type -> {
val config: WeatherWidgetConfig = Json.decodeFromString(entity.config ?: "{}")
WeatherWidget(entity.id, config)
}
} else {
val widgetId = entity.data.toIntOrNull() ?: return null
val widgetInfo =
AppWidgetManager.getInstance(context).getAppWidgetInfo(widgetId) ?: return null
return ExternalWidget(
height = entity.height,
widgetId = widgetId,
widgetProviderInfo = widgetInfo
)
MusicWidget.Type -> MusicWidget(entity.id)
CalendarWidget.Type -> {
val config: CalendarWidgetConfig = Json.decodeFromString(entity.config ?: "{}")
CalendarWidget(entity.id, config)
}
FavoritesWidget.Type -> FavoritesWidget(entity.id)
AppWidget.Type -> {
val config: AppWidgetConfig = Json.decodeFromString(entity.config ?: "{}")
AppWidget(
entity.id,
config,
widgetProviderInfo = AppWidgetManager.getInstance(context)
.getAppWidgetInfo(config.widgetId)
)
}
else -> null
}
}
}
}
object WeatherWidget : Widget() {
override fun loadLabel(context: Context): String {
return context.getString(R.string.widget_name_weather)
}
override fun toDatabaseEntity(position: Int): WidgetEntity {
return WidgetEntity(
type = WidgetType.INTERNAL.value,
data = "weather",
height = -1,
position = position
)
}
override val isConfigurable: Boolean = true
override fun configure(context: Activity, appWidgetHost: AppWidgetHost) {
val intent = Intent()
intent.component = ComponentName(
context.getPackageName(),
"de.mm20.launcher2.ui.settings.SettingsActivity"
)
intent.putExtra(
"de.mm20.launcher2.settings.ROUTE",
"settings/widgets/weather"
)
context.tryStartActivity(intent)
}
}
object MusicWidget : Widget() {
data class MusicWidget(
override val id: UUID,
) : Widget() {
override fun loadLabel(context: Context): String {
return context.getString(R.string.widget_name_music)
}
override fun toDatabaseEntity(position: Int): WidgetEntity {
return WidgetEntity(
type = WidgetType.INTERNAL.value,
data = "music",
height = -1,
position = position
override fun toDatabaseEntity(): PartialWidgetEntity {
return PartialWidgetEntity(
id = id,
type = Type,
config = null
)
}
@ -100,50 +92,27 @@ object MusicWidget : Widget() {
)
context.tryStartActivity(intent)
}
}
object CalendarWidget : Widget() {
override fun loadLabel(context: Context): String {
return context.getString(R.string.widget_name_calendar)
}
override fun toDatabaseEntity(position: Int): WidgetEntity {
return WidgetEntity(
type = WidgetType.INTERNAL.value,
data = "calendar",
height = -1,
position = position
)
}
override val isConfigurable: Boolean = true
override fun configure(context: Activity, appWidgetHost: AppWidgetHost) {
val intent = Intent()
intent.component = ComponentName(
context.getPackageName(),
"de.mm20.launcher2.ui.settings.SettingsActivity"
)
intent.putExtra(
"de.mm20.launcher2.settings.ROUTE",
"settings/widgets/calendar"
)
context.tryStartActivity(intent)
companion object {
const val Type = "music"
}
}
object FavoritesWidget : Widget() {
data class FavoritesWidget(
override val id: UUID,
) : Widget() {
override fun loadLabel(context: Context): String {
return context.getString(R.string.widget_name_favorites)
}
override fun toDatabaseEntity(position: Int): WidgetEntity {
return WidgetEntity(
type = WidgetType.INTERNAL.value,
data = "favorites",
height = -1,
position = position
override fun toDatabaseEntity(): PartialWidgetEntity {
return PartialWidgetEntity(
id = id,
type = Type,
config = null,
)
}
@ -161,44 +130,13 @@ object FavoritesWidget : Widget() {
)
context.tryStartActivity(intent)
}
}
class ExternalWidget(
var height: Int,
val widgetId: Int,
val widgetProviderInfo: AppWidgetProviderInfo
) : Widget() {
override fun loadLabel(context: Context): String {
return widgetProviderInfo.loadLabel(context.packageManager)
}
override fun toDatabaseEntity(position: Int): WidgetEntity {
return WidgetEntity(
type = WidgetType.THIRD_PARTY.value,
data = widgetId.toString(),
height = height,
position = position
)
}
override val isConfigurable: Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
widgetProviderInfo.widgetFeatures and AppWidgetProviderInfo.WIDGET_FEATURE_RECONFIGURABLE != 0
} else {
false
}
override fun configure(context: Activity, appWidgetHost: AppWidgetHost) {
appWidgetHost.startAppWidgetConfigureActivityForResult(
context,
widgetId,
0,
0,
null
)
companion object {
const val Type = "favorites"
}
}
enum class WidgetType(val value: String) {
INTERNAL("internal"),
THIRD_PARTY("3rdparty")

View File

@ -1,11 +1,7 @@
package de.mm20.launcher2.widgets
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.Context
import android.content.pm.LauncherApps
import android.content.pm.PackageManager
import android.util.Log
import androidx.room.withTransaction
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.database.entities.WidgetEntity
@ -16,22 +12,16 @@ import kotlinx.coroutines.flow.map
import org.json.JSONArray
import org.json.JSONException
import java.io.File
import java.util.UUID
interface WidgetRepository {
fun getWidgets(): Flow<List<Widget>>
fun getInternalWidgets(): List<Widget>
fun get(parent: UUID? = null, limit: Int = 100, offset: Int = 0): Flow<List<Widget>>
fun update(widget: Widget)
fun create(widget: Widget, position: Int, parentId: UUID? = null)
fun delete(widget: Widget)
fun set(widgets: List<Widget>, parentId: UUID? = null)
suspend fun getAppWidgets(): List<AppWidgetProviderInfo>
fun saveWidgets(widgets: List<Widget>)
fun addWidget(widget: Widget, position: Int)
fun removeWidget(widget: Widget)
fun setWidgetHeight(widget: Widget, newHeight: Int)
fun isWeatherWidgetEnabled(): Flow<Boolean>
fun isMusicWidgetEnabled(): Flow<Boolean>
fun isCalendarWidgetEnabled(): Flow<Boolean>
fun isFavoritesWidgetEnabled(): Flow<Boolean>
fun isFavoritesWidgetFirst(): Flow<Boolean>
fun exists(type: String): Flow<Boolean>
suspend fun export(toDir: File)
suspend fun import(fromDir: File)
@ -43,92 +33,60 @@ internal class WidgetRepositoryImpl(
) : WidgetRepository {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
override fun getWidgets(): Flow<List<Widget>> {
return database.widgetDao()
.getWidgets()
.map { it.mapNotNull { Widget.fromDatabaseEntity(context, it) } }
}
override fun getInternalWidgets(): List<Widget> {
return listOf(WeatherWidget, MusicWidget, CalendarWidget, FavoritesWidget)
}
override suspend fun getAppWidgets(): List<AppWidgetProviderInfo> {
val appWidgetManager = AppWidgetManager.getInstance(context)
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
val profiles = launcherApps.profiles
val widgets = mutableListOf<AppWidgetProviderInfo>()
withContext(Dispatchers.IO) {
for (profile in profiles) {
widgets.addAll(appWidgetManager.getInstalledProvidersForProfile(profile))
}
override fun get(parent: UUID?, limit: Int, offset: Int): Flow<List<Widget>> {
val dao = database.widgetDao()
return if (parent == null) {
dao.queryRoot(limit, offset)
} else {
dao.queryByParent(parent, limit, offset)
}.map {
it.mapNotNull { Widget.fromDatabaseEntity(context, it) }
}
return widgets
}
override fun saveWidgets(widgets: List<Widget>) {
override fun update(widget: Widget) {
val dao = database.widgetDao()
scope.launch {
withContext(Dispatchers.IO) {
database.widgetDao()
.updateWidgets(widgets.mapIndexed { i, widget -> widget.toDatabaseEntity(i) })
}
dao.patch(widget.toDatabaseEntity())
}
}
override fun addWidget(widget: Widget, position: Int) {
override fun create(widget: Widget, position: Int, parentId: UUID?) {
val dao = database.widgetDao()
scope.launch {
withContext(Dispatchers.IO) {
database.widgetDao()
.insert(widget.toDatabaseEntity(position))
}
val entity = widget.toDatabaseEntity(position = position, parentId = parentId)
dao.insert(entity)
}
}
override fun removeWidget(widget: Widget) {
override fun delete(widget: Widget) {
val dao = database.widgetDao()
scope.launch {
withContext(Dispatchers.IO) {
val ent = widget.toDatabaseEntity()
database.widgetDao().deleteWidget(
ent.type,
ent.data
)
}
dao.delete(widget.id)
}
}
override fun setWidgetHeight(widget: Widget, newHeight: Int) {
override fun set(widgets: List<Widget>, parentId: UUID?) {
val dao = database.widgetDao()
scope.launch {
withContext(Dispatchers.IO) {
val ent = widget.toDatabaseEntity()
database.widgetDao().updateHeight(
ent.type,
ent.data,
newHeight
)
database.withTransaction {
if (parentId == null) {
dao.deleteRoot()
} else {
dao.deleteByParent(parentId)
}
dao.insert(widgets.mapIndexed { index, widget ->
widget.toDatabaseEntity(position = index, parentId = parentId)
})
}
}
}
override fun isWeatherWidgetEnabled(): Flow<Boolean> {
return database.widgetDao().exists("internal", "weather")
override fun exists(type: String): Flow<Boolean> {
val dao = database.widgetDao()
return dao.exists(type = type)
}
override fun isMusicWidgetEnabled(): Flow<Boolean> {
return database.widgetDao().exists("internal", "music")
}
override fun isCalendarWidgetEnabled(): Flow<Boolean> {
return database.widgetDao().exists("internal", "calendar")
}
override fun isFavoritesWidgetEnabled(): Flow<Boolean> {
return database.widgetDao().exists("internal", "favorites")
}
override fun isFavoritesWidgetFirst(): Flow<Boolean> {
return database.widgetDao().getFirst().map { it?.type == "internal" && it.data == "favorites" }
}
override suspend fun export(toDir: File) = withContext(Dispatchers.IO) {
val dao = database.backupDao()
@ -140,13 +98,16 @@ internal class WidgetRepositoryImpl(
if (widget.type != WidgetType.INTERNAL.value) continue
jsonArray.put(
jsonObjectOf(
"data" to widget.data,
"config" to widget.config,
"position" to widget.position,
"type" to widget.type,
"id" to widget.id.toString(),
"parentId" to widget.parentId?.toString(),
)
)
}
val file = File(toDir, "widgets.${page.toString().padStart(4, '0')}")
val file = File(toDir, "widgets2.${page.toString().padStart(4, '0')}")
file.bufferedWriter().use {
it.write(jsonArray.toString())
}
@ -158,7 +119,8 @@ internal class WidgetRepositoryImpl(
val dao = database.backupDao()
dao.wipeWidgets()
val files = fromDir.listFiles { _, name -> name.startsWith("widgets.") } ?: return@withContext
val files =
fromDir.listFiles { _, name -> name.startsWith("widgets2.") } ?: return@withContext
for (file in files) {
val widgets = mutableListOf<WidgetEntity>()
@ -168,10 +130,11 @@ internal class WidgetRepositoryImpl(
for (i in 0 until jsonArray.length()) {
val json = jsonArray.getJSONObject(i)
val entity = WidgetEntity(
type = WidgetType.INTERNAL.value,
type = json.getString("type"),
position = json.getInt("position"),
data = json.getString("data"),
height = -1,
config = json.optString("config"),
id = json.getString("id").let { UUID.fromString(it) },
parentId = json.optString("parentId").let { if (it.isEmpty()) null else UUID.fromString(it) }
)
widgets.add(entity)
}

View File

@ -3,7 +3,7 @@ package de.mm20.launcher2.backup
enum class BackupComponent(val value: String) {
Settings("settings"),
Favorites("favorites"),
Widgets("widgets"),
Widgets("widgets2"),
Customizations("customizations"),
SearchActions("searchactions");

View File

@ -188,7 +188,7 @@ class BackupManager(
*/
private const val BackupFormatMajor = 1
private const val BackupFormatMinor = 5
private const val BackupFormatMinor = 6
internal const val BackupFormat = "$BackupFormatMajor.$BackupFormatMinor"
}
}

1
services/widgets/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,46 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.services.widgets"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.koin.android)
implementation(project(":core:base"))
implementation(project(":core:i18n"))
implementation(project(":data:widgets"))
}

View File

21
services/widgets/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -0,0 +1,7 @@
package de.mm20.launcher2.services.widgets
data class BuiltInWidgetInfo(
val type: String,
val label: String,
)

View File

@ -0,0 +1,9 @@
package de.mm20.launcher2.services.widgets
import WidgetsService
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val widgetsServiceModule = module {
single { WidgetsService(androidContext(), get()) }
}

View File

@ -0,0 +1,71 @@
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.Context
import android.content.pm.LauncherApps
import androidx.core.content.getSystemService
import de.mm20.launcher2.services.widgets.BuiltInWidgetInfo
import de.mm20.launcher2.services.widgets.R
import de.mm20.launcher2.widgets.CalendarWidget
import de.mm20.launcher2.widgets.FavoritesWidget
import de.mm20.launcher2.widgets.MusicWidget
import de.mm20.launcher2.widgets.WeatherWidget
import de.mm20.launcher2.widgets.Widget
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.util.UUID
class WidgetsService(
private val context: Context,
private val widgetRepository: WidgetRepository,
) {
suspend fun getAppWidgetProviders(): List<AppWidgetProviderInfo> = withContext(Dispatchers.IO) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val launcherApps = context.getSystemService<LauncherApps>() ?: return@withContext emptyList()
val profiles = launcherApps.profiles
val widgets = mutableListOf<AppWidgetProviderInfo>()
for (profile in profiles) {
widgets.addAll(appWidgetManager.getInstalledProvidersForProfile(profile))
}
widgets
}
fun getBuiltInWidgets(): List<BuiltInWidgetInfo> {
return listOf(
BuiltInWidgetInfo(
type = WeatherWidget.Type,
label = context.getString(R.string.widget_name_weather),
),
BuiltInWidgetInfo(
type = MusicWidget.Type,
label = context.getString(R.string.widget_name_music),
),
BuiltInWidgetInfo(
type = CalendarWidget.Type,
label = context.getString(R.string.widget_name_calendar),
),
BuiltInWidgetInfo(
type = FavoritesWidget.Type,
label = context.getString(R.string.widget_name_favorites),
),
)
}
fun addWidget(widget: Widget, position: Int, parentId: UUID? = null) {
widgetRepository.create(widget, position, parentId)
}
fun getWidgets() = widgetRepository.get()
fun isFavoritesWidgetFirst(): Flow<Boolean> {
return widgetRepository.get(limit = 1).map {
it.firstOrNull() is FavoritesWidget
}
}
companion object {
const val AppWidgetHostId = 44203
}
}

View File

@ -30,6 +30,9 @@ dependencyResolutionManagement {
"kotlinx.collections.immutable"
)
)
version("kotlinx.serialization", "1.5.0")
library("kotlinx.serialization.json", "org.jetbrains.kotlinx", "kotlinx-serialization-json")
.versionRef("kotlinx.serialization")
version("androidx.compose.compiler", "1.4.4")
library("androidx.compose.runtime", "androidx.compose.runtime", "runtime")
@ -297,3 +300,4 @@ include(":libs:webdav")
include(":libs:g-services")
include(":libs:ms-services")
include(":services:global-actions")
include(":services:widgets")