feat: host app widgets on the widgets screen (K-9 unread widget)

This commit is contained in:
2026-08-19 16:16:47 +02:00
parent 09cc6a557d
commit daa1748858
11 changed files with 617 additions and 0 deletions

View File

@@ -144,6 +144,60 @@ Prefs live in `/data/data/app.easy.launcher/shared_prefs/`. To change them:
can seed e.g. Haugesund 59.4138 / 5.2680 (remember the `.xml` suffix + can seed e.g. Haugesund 59.4138 / 5.2680 (remember the `.xml` suffix +
`restorecon`!). `restorecon`!).
### App widgets on the Widgets screen
- Manager: `service/WidgetHostManager.kt` (`@Singleton`) wraps
`AppWidgetHost` + `AppWidgetManager`. Host id is a fixed constant
(0x4554); placed widgets are persisted in `EasyLauncher.pref.xml` as a JSON
array of `[appWidgetId, flattenedComponent]` pairs under the `HOSTED_WIDGETS`
key.
- The launcher is the *host* only — it does NOT hold `BIND_APPWIDGET`. On
Android 12+ `bindAppWidgetIdIfAllowed` requires the caller to hold
`BIND_APPWIDGET` **or** be in the system bind-widget allowlist stored in
`/data/system/users/<user>/appwidgets.xml` as `<b packageName="..."/>`
(loaded into `mPackagesWithBindWidgetPermission`). Just being the default
home app is NOT enough. `launcher3` works because it is a priv-app with
`BIND_APPWIDGET`; a third-party launcher needs the grant. On this rooted
phone that grant was added manually for `app.easy.launcher` (see below).
- Feature toggle: `SHOW_HOME_WIDGETS` (default OFF, label "App widgets" in
Settings → Features). Widgets render on the **Widgets** screen (the
`WidgetFragment` reached by the swipe gesture, `ShowWidgets`), not the home
screen. `WidgetFragment.setupHostedWidgets()` restores persisted views and
`startListening`/`stopListening` run on `onStart`/`onStop`. **Note:**
`WidgetFragment.orderWidgetsBySettings()` does `removeAllViews()` on the
scroll container and re-adds only the self-drawn widgets — `widgetHostArea`
must be re-added there or it silently vanishes.
- Add flow: "Add widget" → provider picker → `requestAddWidget` (allocate +
bind + launch the provider's configure activity via
`startAppWidgetConfigureActivityForResult`). Result arrives in
`MainActivity.onActivityResult` (request code `WidgetHostManager.REQUEST_ADD_WIDGET`)
→ `onConfigureResult` → view created + persisted. The picker is dismissed on
selection.
- Remove: each hosted widget gets a small "✕" overlay owned by us (top-right
of the wrapped view) plus a long-press handler. Long-press may not fire on
fully interactive widgets (e.g. K-9's counts widget opens the app on
touch), so the "✕" is the reliable path — both call `removeWidget`.
- Restore is **non-destructive under CE-lock**: if `getAppWidgetInfo` is null
while the user is locked it keeps the persisted record and skips (widgets
reappear after the PIN is entered and the effect is revisited); it only
drops a record when the user is unlocked. This is what makes widgets survive
a reboot.
- Grant on this phone (root), i.e. how `app.easy.launcher` got bindable:
1. `adb shell "su -c 'stop'"` (so the running service can't overwrite the
edit on save).
2. `abx2xml /data/system/users/0/appwidgets.xml /data/local/tmp/aw.xml`, add
`<b packageName="app.easy.launcher" />`, `xml2abx` back, `restorecon -F`.
3. `adb shell "su -c 'start'"`; verify with
`dumpsys appwidget | grep -A2 Grants` → `user=0 package=app.easy.launcher`.
Editing the file while the service is running is useless: the shutdown save
overwrites it.
- K-9's "Unread count" widget: provider
`com.fsck.k9/com.fsck.k9.provider.UnreadWidgetProvider`, configure activity
`app.k9mail.feature.widget.unread.UnreadWidgetConfigurationActivity`. Both
are enabled by default on this K-9 build (`enabled=0` manifests as
resolvable). Placing it asks for an account/folder ("Unified Inbox" = all
accounts) and then renders a persistent live unread count.
## Known behavior quirks ## Known behavior quirks
- The accessibility-service dialog ("Please turn on accessibility service to - The accessibility-service dialog ("Please turn on accessibility service to

View File

@@ -206,6 +206,10 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
get() = prefs.getBoolean(Constants.SHOW_CURRENT_WEATHER, false) get() = prefs.getBoolean(Constants.SHOW_CURRENT_WEATHER, false)
set(value) = prefs.edit().putBoolean(Constants.SHOW_CURRENT_WEATHER, value).apply() set(value) = prefs.edit().putBoolean(Constants.SHOW_CURRENT_WEATHER, value).apply()
var showHomeWidgets: Boolean
get() = prefs.getBoolean(Constants.SHOW_HOME_WIDGETS, false)
set(value) = prefs.edit().putBoolean(Constants.SHOW_HOME_WIDGETS, value).apply()
var searchEngines: Constants.SearchEngines var searchEngines: Constants.SearchEngines
get() { get() {
return try { return try {

View File

@@ -0,0 +1,285 @@
package com.github.droidworksstudio.launcher.service
import android.app.Activity
import android.appwidget.AppWidgetHost
import android.appwidget.AppWidgetHostView
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.ComponentName
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.os.UserManager
import android.util.Log
import com.github.droidworksstudio.launcher.utils.Constants
import dagger.hilt.android.qualifiers.ApplicationContext
import org.json.JSONArray
import javax.inject.Inject
import javax.inject.Singleton
/**
* Hosts real Android app widgets (e.g. K-9 Mail's "K-9 Unread") on the launcher
* home screen. This is the launcher's [AppWidgetHost]: it allocates/binds widget
* ids, recreates [AppWidgetHostView]s after a process restart or reboot, and
* persists the placed widgets across sessions.
*
* Placed widgets are stored as a list of `[appWidgetId, flattenedComponent]`
* pairs in SharedPreferences under [Constants.HOSTED_WIDGETS]. The widget ids
* belong to this launcher's host; views are re-created from those ids on every
* home-screen visit. This host does NOT need BIND_APPWIDGET permission — only
* the app that the user sets as the default home can bind widgets, which is how
* Android enforces the launcher role.
*/
@Singleton
class WidgetHostManager @Inject constructor(@ApplicationContext private val context: Context) {
private val appWidgetManager = AppWidgetManager.getInstance(context)
private val host = AppWidgetHost(context, HOST_ID)
private val prefs = context.getSharedPreferences(Constants.PACKAGE_PREFS, Context.MODE_PRIVATE)
/** A single placed widget, as recorded in prefs. */
private data class WidgetRecord(
val appWidgetId: Int,
val providerFlattened: String,
)
companion object {
private const val TAG = "WidgetHost"
/** Arbitrary but unique per-host id for this launcher package. */
private const val HOST_ID = 0x4554 // "ET" -- Easy Launcher widget host
/** Request code that MainActivity uses to receive the widget configure result. */
const val REQUEST_ADD_WIDGET = 4001
}
/** Begin receiving app-widget update broadcasts for the placed widgets. */
fun startListening() {
try {
host.startListening()
} catch (e: Exception) {
Log.e(TAG, "startListening failed", e)
}
}
/** Stop receiving app-widget updates (call from onStop). */
fun stopListening() {
try {
host.stopListening()
} catch (e: Exception) {
Log.e(TAG, "stopListening failed", e)
}
}
/**
* All installed widget providers, sorted by label. Uses the profile-aware
* variant on API 33+ where the old [AppWidgetManager.getInstalledProviders]
* is deprecated.
*/
fun installedProviders(): List<AppWidgetProviderInfo> {
val all = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
appWidgetManager.getInstalledProvidersForProfile(Process.myUserHandle())
} else {
@Suppress("DEPRECATION")
appWidgetManager.getInstalledProviders()
}
return all.sortedBy { it.loadLabel(context.packageManager)?.toString() ?: "" }
}
/**
* Re-create [AppWidgetHostView]s for every widget persisted in prefs, ready
* to be added to the home widget container. Entries whose widget id no
* longer resolves to a live provider are pruned, but only once the user is
* unlocked — right after a reboot the phone is still CE-locked and widget
* state is not yet visible, so we must not drop persisted widgets then.
*/
fun restoreWidgetViews(): List<AppWidgetHostView> {
val records = readWidgetRecords()
if (records.isEmpty()) return emptyList()
val views = mutableListOf<AppWidgetHostView>()
val dropped = mutableListOf<WidgetRecord>()
for (rec in records) {
val info = appWidgetManager.getAppWidgetInfo(rec.appWidgetId)
if (info == null) {
if (isUserUnlocked()) {
Log.w(TAG, "restore: widget ${rec.appWidgetId} no longer bound -- dropping")
dropped += rec
} else {
Log.d(TAG, "restore: widget ${rec.appWidgetId} unavailable while locked -- skipped")
}
continue
}
try {
views += host.createView(context, rec.appWidgetId, info)
} catch (e: Exception) {
Log.e(TAG, "restore: createView failed for ${rec.appWidgetId}", e)
}
}
if (dropped.isNotEmpty()) {
val remaining = records - dropped.toSet()
writeWidgetRecords(remaining)
}
return views
}
/** Outcome of a widget-add request, reported back to the caller. */
sealed interface AddResult {
/** Widget bound and its view is ready to be placed on the home screen. */
data class Bound(val view: AppWidgetHostView) : AddResult
/** The package is not the active home app, so binding was refused. */
object BindDenied : AddResult
/** The user cancelled the provider's configure activity. */
object Cancelled : AddResult
}
/**
* Start the add-widget flow for [provider].
*
* - Allocates a new widget id and binds it. Binding fails when this
* package is not the active home app; [callback] then receives
* [AddResult.BindDenied].
* - If the provider has a configure activity, it is launched
* (result arrives via [onConfigureResult]) and the callback is deferred
* until the user confirms.
* - Otherwise a fully bound [AppWidgetHostView] is created immediately and
* returned through [callback].
*/
fun requestAddWidget(
activity: Activity,
provider: ComponentName,
callback: (AddResult) -> Unit,
) {
val appWidgetId = host.allocateAppWidgetId()
if (!appWidgetManager.bindAppWidgetIdIfAllowed(appWidgetId, provider)) {
Log.w(TAG, "bind not allowed for $provider -- not the active home app?")
host.deleteAppWidgetId(appWidgetId)
callback(AddResult.BindDenied)
return
}
val info = appWidgetManager.getAppWidgetInfo(appWidgetId)
if (info == null) {
host.deleteAppWidgetId(appWidgetId)
callback(AddResult.BindDenied)
return
}
if (info.configure != null) {
// Defer: launch the provider's configure activity; finish later in
// onConfigureResult (MainActivity.onActivityResult).
pendingAdd = PendingAdd(appWidgetId, provider)
addCallback = callback
try {
host.startAppWidgetConfigureActivityForResult(
activity,
appWidgetId,
0,
REQUEST_ADD_WIDGET,
Bundle()
)
} catch (e: Exception) {
Log.e(TAG, "startAppWidgetConfigureActivityForResult failed", e)
host.deleteAppWidgetId(appWidgetId)
pendingAdd = null
addCallback = null
callback(AddResult.Cancelled)
}
} else {
val view = host.createView(context, appWidgetId, info)
persistNewWidget(appWidgetId, provider)
callback(AddResult.Bound(view))
}
}
/**
* Called from MainActivity.onActivityResult when the configure activity
* launched by [requestAddWidget] returns. On OK the widget is bound, the
* view is created and the deferred callback fires with [AddResult.Bound]; on
* cancel the allocated id is released and [AddResult.Cancelled] fires.
*/
fun onConfigureResult(resultCode: Int) {
val pending = pendingAdd ?: return
val callback = addCallback
pendingAdd = null
addCallback = null
if (resultCode != Activity.RESULT_OK || callback == null) {
host.deleteAppWidgetId(pending.appWidgetId)
callback?.invoke(AddResult.Cancelled)
return
}
val info = appWidgetManager.getAppWidgetInfo(pending.appWidgetId)
if (info == null) {
host.deleteAppWidgetId(pending.appWidgetId)
callback(AddResult.BindDenied)
return
}
val view = host.createView(context, pending.appWidgetId, info)
persistNewWidget(pending.appWidgetId, pending.provider)
callback(AddResult.Bound(view))
}
/**
* Remove a hosted widget: detach its view, release the host widget id and
* drop the persisted record.
*/
fun removeWidget(view: AppWidgetHostView, appWidgetId: Int) {
(view.parent as? android.view.ViewGroup)?.removeView(view)
host.deleteAppWidgetId(appWidgetId)
val remaining = readWidgetRecords().filterNot { it.appWidgetId == appWidgetId }
writeWidgetRecords(remaining)
}
/** Number of widgets currently persisted (for showing/hiding the area). */
fun placedWidgetCount(): Int = readWidgetRecords().size
private var pendingAdd: PendingAdd? = null
private var addCallback: ((AddResult) -> Unit)? = null
private data class PendingAdd(val appWidgetId: Int, val provider: ComponentName)
private fun persistNewWidget(appWidgetId: Int, provider: ComponentName) {
val records = readWidgetRecords().apply {
removeAll { it.appWidgetId == appWidgetId }
}
records += WidgetRecord(appWidgetId, provider.flattenToString())
writeWidgetRecords(records)
}
private fun readWidgetRecords(): MutableList<WidgetRecord> {
val json = prefs.getString(Constants.HOSTED_WIDGETS, null) ?: return mutableListOf()
return try {
val arr = JSONArray(json)
val out = mutableListOf<WidgetRecord>()
for (i in 0 until arr.length()) {
val pair = arr.getJSONArray(i)
out += WidgetRecord(pair.getInt(0), pair.getString(1))
}
out
} catch (e: Exception) {
Log.e(TAG, "failed to parse hosted widgets", e)
mutableListOf()
}
}
private fun writeWidgetRecords(records: List<WidgetRecord>) {
val arr = JSONArray()
records.forEach { rec ->
arr.put(JSONArray().apply { put(rec.appWidgetId); put(rec.providerFlattened) })
}
prefs.edit().putString(Constants.HOSTED_WIDGETS, arr.toString()).apply()
}
private fun isUserUnlocked(): Boolean {
return try {
context.getSystemService(UserManager::class.java)?.isUserUnlocked ?: true
} catch (e: Exception) {
true
}
}
}

View File

@@ -41,6 +41,7 @@ import com.github.droidworksstudio.launcher.helper.AppHelper
import com.github.droidworksstudio.launcher.helper.AppReloader import com.github.droidworksstudio.launcher.helper.AppReloader
import com.github.droidworksstudio.launcher.helper.PreferenceHelper import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.repository.AppInfoRepository import com.github.droidworksstudio.launcher.repository.AppInfoRepository
import com.github.droidworksstudio.launcher.service.WidgetHostManager
import com.github.droidworksstudio.launcher.utils.Constants import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel
@@ -77,6 +78,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var appDao: AppInfoDAO lateinit var appDao: AppInfoDAO
@Inject
lateinit var widgetHostManager: WidgetHostManager
private lateinit var sharedPreferences: SharedPreferences private lateinit var sharedPreferences: SharedPreferences
private lateinit var handler: Handler private lateinit var handler: Handler
@@ -413,6 +417,13 @@ class MainActivity : AppCompatActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
// Widget configure activity finished; hand off to the widget host before
// the generic error handling below (which would toast on cancel).
if (requestCode == WidgetHostManager.REQUEST_ADD_WIDGET) {
widgetHostManager.onConfigureResult(resultCode)
return
}
if (resultCode != RESULT_OK) { if (resultCode != RESULT_OK) {
applicationContext.showLongToast("Intent Error") applicationContext.showLongToast("Intent Error")
return return

View File

@@ -135,6 +135,7 @@ class SettingsFeaturesFragment : Fragment(),
lockSettingsSwitchCompat.isChecked = preferenceHelper.settingsLock lockSettingsSwitchCompat.isChecked = preferenceHelper.settingsLock
disableAnimationsSwitchCompat.isChecked = preferenceHelper.disableAnimations disableAnimationsSwitchCompat.isChecked = preferenceHelper.disableAnimations
showNotificationDotsSwitchCompat.isChecked = preferenceHelper.showNotificationDots showNotificationDotsSwitchCompat.isChecked = preferenceHelper.showNotificationDots
showHomeWidgetsSwitchCompat.isChecked = preferenceHelper.showHomeWidgets
} }
} }
@@ -466,6 +467,12 @@ class SettingsFeaturesFragment : Fragment(),
openNotificationAccessSettings() openNotificationAccessSettings()
} }
} }
showHomeWidgetsSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
preferenceViewModel.setShowHomeWidgets(isChecked)
val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType)
}
} }
} }

View File

@@ -1,7 +1,9 @@
package com.github.droidworksstudio.launcher.ui.widgets package com.github.droidworksstudio.launcher.ui.widgets
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.appwidget.AppWidgetHostView
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
@@ -16,8 +18,12 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@@ -35,7 +41,9 @@ import com.github.droidworksstudio.launcher.helper.AppHelper
import com.github.droidworksstudio.launcher.helper.PreferenceHelper import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
import com.github.droidworksstudio.launcher.listener.ScrollEventListener import com.github.droidworksstudio.launcher.listener.ScrollEventListener
import com.github.droidworksstudio.launcher.service.WidgetHostManager
import com.github.droidworksstudio.launcher.utils.Constants import com.github.droidworksstudio.launcher.utils.Constants
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -62,6 +70,9 @@ class WidgetFragment : Fragment(),
@Inject @Inject
lateinit var appHelper: AppHelper lateinit var appHelper: AppHelper
@Inject
lateinit var widgetHostManager: WidgetHostManager
private lateinit var navController: NavController private lateinit var navController: NavController
private lateinit var context: Context private lateinit var context: Context
@@ -88,6 +99,7 @@ class WidgetFragment : Fragment(),
setupBatteryWidget() setupBatteryWidget()
observeClickListener() observeClickListener()
observeSwipeTouchListener() observeSwipeTouchListener()
setupHostedWidgets()
} }
private fun initializeInjectedDependencies() { private fun initializeInjectedDependencies() {
@@ -119,6 +131,10 @@ class WidgetFragment : Fragment(),
linearLayout.addView(relativeLayout) linearLayout.addView(relativeLayout)
} }
// Hosted app widgets always live after the self-drawn widgets; this
// must be re-added here because removeAllViews() above dropped it.
linearLayout.addView(binding.widgetHostArea)
} }
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
@@ -426,6 +442,168 @@ class WidgetFragment : Fragment(),
} }
} }
// ---------------------------------------------------------------------
// Hosted app widgets (see WidgetHostManager)
// ---------------------------------------------------------------------
override fun onStart() {
super.onStart()
if (preferenceHelper.showHomeWidgets) {
widgetHostManager.startListening()
}
}
override fun onStop() {
if (preferenceHelper.showHomeWidgets) {
widgetHostManager.stopListening()
}
super.onStop()
}
/** Set up the add-widget affordance and recreate any persisted widgets. */
private fun setupHostedWidgets() {
binding.addWidgetButton.setOnClickListener { showWidgetPicker() }
restoreHostedWidgets()
}
/** Re-create views for every widget persisted in prefs and show the area. */
private fun restoreHostedWidgets() {
binding.widgetHostContainer.removeAllViews()
refreshWidgetAreaVisibility()
if (!preferenceHelper.showHomeWidgets) return
widgetHostManager.restoreWidgetViews().forEach { attachWidgetView(it) }
}
/** Open a picker of installed widget providers. */
private fun showWidgetPicker() {
val providers = widgetHostManager.installedProviders()
if (providers.isEmpty()) {
requireContext().showLongToast(getString(R.string.widget_none_available))
return
}
val density = requireContext().resources.displayMetrics.densityDpi
val dialogContent = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
val paddingDp = (8f * resources.displayMetrics.density)
setPadding(0, paddingDp.toInt(), 0, paddingDp.toInt())
}
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.widget_picker_title)
.setNegativeButton(R.string.settings_cancel, null)
.create()
val scroll = android.widget.ScrollView(requireContext())
dialog.setView(scroll)
providers.forEach { provider ->
val row = AppCompatTextView(requireContext()).apply {
text = provider.loadLabel(context.packageManager)
textSize = 16f
val hPad = (12f * resources.displayMetrics.density).toInt()
val vPad = (14f * resources.displayMetrics.density).toInt()
setPadding(hPad, vPad, hPad, vPad)
isClickable = true
val icon = provider.loadIcon(requireContext(), density)
setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
compoundDrawablePadding = hPad
setOnClickListener {
dialog.dismiss()
addWidget(provider.provider)
}
}
dialogContent.addView(row)
}
scroll.addView(dialogContent)
dialog.show()
}
/** Run the manager's add-widget flow and place the resulting view. */
private fun addWidget(provider: ComponentName) {
widgetHostManager.requestAddWidget(requireActivity(), provider) { result ->
when (result) {
is WidgetHostManager.AddResult.Bound -> attachWidgetView(result.view)
WidgetHostManager.AddResult.BindDenied -> requireContext().showLongToast(
getString(R.string.widget_bind_failed)
)
WidgetHostManager.AddResult.Cancelled -> {} // user backed out
}
}
}
/** Add a hosted widget view to the container with a small remove affordance. */
private fun attachWidgetView(view: AppWidgetHostView) {
val density = resources.displayMetrics.density
val wrapper = FrameLayout(requireContext())
wrapper.layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
wrapper.addView(
view,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
)
// A small "x" in the top-right corner, owned by us (not the remote
// widget content) so removal works even for fully interactive widgets
// like K-9's, whose own taps open the app instead of reaching a
// long-press listener.
val removeButton = AppCompatTextView(requireContext()).apply {
text = "\u2715"
textSize = 16f
setTextColor(0x6688AAAA.toInt())
val pad = (5f * density).toInt()
setPadding(pad, pad, pad, pad)
gravity = Gravity.CENTER
setOnClickListener { confirmRemoveWidget(view, wrapper) }
}
wrapper.addView(
removeButton,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply { gravity = Gravity.END or Gravity.TOP }
)
binding.widgetHostContainer.addView(wrapper)
// Secondary path: long-press still removes where the widget is not
// consuming touches.
view.setOnLongClickListener {
confirmRemoveWidget(view, wrapper)
true
}
refreshWidgetAreaVisibility()
}
private fun confirmRemoveWidget(view: AppWidgetHostView, wrapper: ViewGroup) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.widget_remove_title)
.setMessage(R.string.widget_remove_message)
.setPositiveButton(R.string.widget_remove) { _, _ ->
val id = view.appWidgetId
widgetHostManager.removeWidget(view, id)
binding.widgetHostContainer.removeView(wrapper)
requireContext().showLongToast(getString(R.string.widget_deleted))
refreshWidgetAreaVisibility()
}
.setNegativeButton(R.string.settings_cancel, null)
.show()
}
/** Show/hide the hosted-widget area based on the feature toggle. */
private fun refreshWidgetAreaVisibility() {
binding.widgetHostArea.visibility =
if (preferenceHelper.showHomeWidgets) View.VISIBLE else View.GONE
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
context.registerReceiver(batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) context.registerReceiver(batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))

View File

@@ -76,6 +76,8 @@ object Constants {
const val DISABLE_ANIMATIONS = "DISABLE_ANIMATIONS" const val DISABLE_ANIMATIONS = "DISABLE_ANIMATIONS"
const val SHOW_NOTIFICATION_DOTS = "SHOW_NOTIFICATION_DOTS" const val SHOW_NOTIFICATION_DOTS = "SHOW_NOTIFICATION_DOTS"
const val SHOW_CURRENT_WEATHER = "SHOW_CURRENT_WEATHER" const val SHOW_CURRENT_WEATHER = "SHOW_CURRENT_WEATHER"
const val SHOW_HOME_WIDGETS = "SHOW_HOME_WIDGETS"
const val HOSTED_WIDGETS = "HOSTED_WIDGETS"
const val HOME_DATE_ALIGNMENT = "HOME_DATE_ALIGNMENT" const val HOME_DATE_ALIGNMENT = "HOME_DATE_ALIGNMENT"
const val HOME_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT" const val HOME_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT"

View File

@@ -51,6 +51,7 @@ class PreferenceViewModel @Inject constructor(
private val disableAnimationsLiveData: MutableLiveData<Boolean> = MutableLiveData() private val disableAnimationsLiveData: MutableLiveData<Boolean> = MutableLiveData()
val showNotificationDotsLiveData: MutableLiveData<Boolean> = MutableLiveData() val showNotificationDotsLiveData: MutableLiveData<Boolean> = MutableLiveData()
val showCurrentWeatherLiveData: MutableLiveData<Boolean> = MutableLiveData() val showCurrentWeatherLiveData: MutableLiveData<Boolean> = MutableLiveData()
val showHomeWidgetsLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val appGroupPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData() private val appGroupPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
private val appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData() private val appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
@@ -309,6 +310,11 @@ class PreferenceViewModel @Inject constructor(
showCurrentWeatherLiveData.postValue((preferenceHelper.showCurrentWeather)) showCurrentWeatherLiveData.postValue((preferenceHelper.showCurrentWeather))
} }
fun setShowHomeWidgets(showHomeWidgets: Boolean) {
preferenceHelper.showHomeWidgets = showHomeWidgets
showHomeWidgetsLiveData.postValue((preferenceHelper.showHomeWidgets))
}
fun setAppLanguage(appLanguage: Constants.Language) { fun setAppLanguage(appLanguage: Constants.Language) {
preferenceHelper.appLanguage = appLanguage preferenceHelper.appLanguage = appLanguage
appLanguageLiveData.postValue((preferenceHelper.appLanguage)) appLanguageLiveData.postValue((preferenceHelper.appLanguage))

View File

@@ -269,6 +269,37 @@
tools:ignore="TouchTargetSizeCheck" /> tools:ignore="TouchTargetSizeCheck" />
</androidx.appcompat.widget.LinearLayoutCompat> </androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
tools:ignore="MissingConstraints">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/showHomeWidgets_text"
style="@style/TextDefaultStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="left|center"
android:text="@string/settings_display_home_widgets"
android:textSize="@dimen/text_large"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlHardcoded" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/showHomeWidgets_switchCompat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleX="0.7"
android:scaleY="0.8"
android:thumb="@drawable/shape_switch_thumb"
app:track="@drawable/selector_switch"
tools:ignore="TouchTargetSizeCheck" />
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat> </androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat <androidx.appcompat.widget.LinearLayoutCompat

View File

@@ -297,6 +297,36 @@
android:textSize="14sp" /> android:textSize="14sp" />
</LinearLayout> </LinearLayout>
</RelativeLayout> </RelativeLayout>
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/widgetHostArea"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:orientation="vertical"
android:visibility="gone">
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/widgetHostContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/addWidgetButton"
style="@style/TextDefaultStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="12dp"
android:text="@string/widget_add"
android:textColor="@color/icon_200"
android:textSize="16sp"
tools:ignore="TouchTargetSizeCheck" />
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat> </androidx.appcompat.widget.LinearLayoutCompat>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</FrameLayout> </FrameLayout>

View File

@@ -112,7 +112,16 @@
<string name="settings_display_lock_settings">Lock Settings</string> <string name="settings_display_lock_settings">Lock Settings</string>
<string name="settings_display_disable_animations">Disable Animations</string> <string name="settings_display_disable_animations">Disable Animations</string>
<string name="settings_display_notification_dots">Notification Dots</string> <string name="settings_display_notification_dots">Notification Dots</string>
<string name="settings_display_home_widgets">App widgets</string>
<string name="toast_cannot_open_notification_access_settings">Cannot open notification access settings.</string> <string name="toast_cannot_open_notification_access_settings">Cannot open notification access settings.</string>
<string name="widget_add">Add widget</string>
<string name="widget_picker_title">Add widget</string>
<string name="widget_remove_title">Remove widget</string>
<string name="widget_remove_message">Remove this widget?</string>
<string name="widget_remove">Remove</string>
<string name="widget_deleted">Widget removed</string>
<string name="widget_none_available">No widget providers installed</string>
<string name="widget_bind_failed">This widget cannot be added right now.</string>
<string name="settings_appearance_text_size_title">Size</string> <string name="settings_appearance_text_size_title">Size</string>
<string name="settings_appearance_color_title">Color</string> <string name="settings_appearance_color_title">Color</string>