From daa17488584332d36c6e8961029554490abe7a9b Mon Sep 17 00:00:00 2001 From: Jonas Haugesen Date: Wed, 19 Aug 2026 16:16:47 +0200 Subject: [PATCH] feat: host app widgets on the widgets screen (K-9 unread widget) --- CLAUDE.md | 54 ++++ .../launcher/helper/PreferenceHelper.kt | 4 + .../launcher/service/WidgetHostManager.kt | 285 ++++++++++++++++++ .../launcher/ui/activities/MainActivity.kt | 11 + .../ui/settings/SettingsFeaturesFragment.kt | 7 + .../launcher/ui/widgets/WidgetFragment.kt | 178 +++++++++++ .../launcher/utils/Constants.kt | 2 + .../launcher/viewmodel/PreferenceViewModel.kt | 6 + .../res/layout/fragment_settings_features.xml | 31 ++ app/src/main/res/layout/fragment_widgets.xml | 30 ++ app/src/main/res/values/strings.xml | 9 + 11 files changed, 617 insertions(+) create mode 100644 app/src/main/java/com/github/droidworksstudio/launcher/service/WidgetHostManager.kt diff --git a/CLAUDE.md b/CLAUDE.md index cfa707d..fa6f85c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + `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//appwidgets.xml` as `` + (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 + ``, `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 - The accessibility-service dialog ("Please turn on accessibility service to diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/helper/PreferenceHelper.kt b/app/src/main/java/com/github/droidworksstudio/launcher/helper/PreferenceHelper.kt index b4f7a35..982026a 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/helper/PreferenceHelper.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/helper/PreferenceHelper.kt @@ -206,6 +206,10 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context) get() = prefs.getBoolean(Constants.SHOW_CURRENT_WEATHER, false) 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 get() { return try { diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/service/WidgetHostManager.kt b/app/src/main/java/com/github/droidworksstudio/launcher/service/WidgetHostManager.kt new file mode 100644 index 0000000..993ba87 --- /dev/null +++ b/app/src/main/java/com/github/droidworksstudio/launcher/service/WidgetHostManager.kt @@ -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 { + 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 { + val records = readWidgetRecords() + if (records.isEmpty()) return emptyList() + + val views = mutableListOf() + val dropped = mutableListOf() + 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 { + val json = prefs.getString(Constants.HOSTED_WIDGETS, null) ?: return mutableListOf() + return try { + val arr = JSONArray(json) + val out = mutableListOf() + 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) { + 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 + } + } +} diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/ui/activities/MainActivity.kt b/app/src/main/java/com/github/droidworksstudio/launcher/ui/activities/MainActivity.kt index aa4c871..c45da8e 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/ui/activities/MainActivity.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/ui/activities/MainActivity.kt @@ -41,6 +41,7 @@ import com.github.droidworksstudio.launcher.helper.AppHelper import com.github.droidworksstudio.launcher.helper.AppReloader import com.github.droidworksstudio.launcher.helper.PreferenceHelper 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.viewmodel.AppViewModel import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel @@ -77,6 +78,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var appDao: AppInfoDAO + @Inject + lateinit var widgetHostManager: WidgetHostManager + private lateinit var sharedPreferences: SharedPreferences private lateinit var handler: Handler @@ -413,6 +417,13 @@ class MainActivity : AppCompatActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 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) { applicationContext.showLongToast("Intent Error") return diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/ui/settings/SettingsFeaturesFragment.kt b/app/src/main/java/com/github/droidworksstudio/launcher/ui/settings/SettingsFeaturesFragment.kt index 1e11161..401372c 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/ui/settings/SettingsFeaturesFragment.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/ui/settings/SettingsFeaturesFragment.kt @@ -135,6 +135,7 @@ class SettingsFeaturesFragment : Fragment(), lockSettingsSwitchCompat.isChecked = preferenceHelper.settingsLock disableAnimationsSwitchCompat.isChecked = preferenceHelper.disableAnimations showNotificationDotsSwitchCompat.isChecked = preferenceHelper.showNotificationDots + showHomeWidgetsSwitchCompat.isChecked = preferenceHelper.showHomeWidgets } } @@ -466,6 +467,12 @@ class SettingsFeaturesFragment : Fragment(), openNotificationAccessSettings() } } + + showHomeWidgetsSwitchCompat.setOnCheckedChangeListener { _, isChecked -> + preferenceViewModel.setShowHomeWidgets(isChecked) + val feedbackType = if (isChecked) "on" else "off" + appHelper.triggerHapticFeedback(context, feedbackType) + } } } diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/ui/widgets/WidgetFragment.kt b/app/src/main/java/com/github/droidworksstudio/launcher/ui/widgets/WidgetFragment.kt index d9ad4f5..e7ef93e 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/ui/widgets/WidgetFragment.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/ui/widgets/WidgetFragment.kt @@ -1,7 +1,9 @@ package com.github.droidworksstudio.launcher.ui.widgets import android.annotation.SuppressLint +import android.appwidget.AppWidgetHostView import android.content.BroadcastReceiver +import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -16,8 +18,12 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.view.LayoutInflater +import android.view.Gravity import android.view.View 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.res.ResourcesCompat 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.listener.OnSwipeTouchListener import com.github.droidworksstudio.launcher.listener.ScrollEventListener +import com.github.droidworksstudio.launcher.service.WidgetHostManager import com.github.droidworksstudio.launcher.utils.Constants +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -62,6 +70,9 @@ class WidgetFragment : Fragment(), @Inject lateinit var appHelper: AppHelper + @Inject + lateinit var widgetHostManager: WidgetHostManager + private lateinit var navController: NavController private lateinit var context: Context @@ -88,6 +99,7 @@ class WidgetFragment : Fragment(), setupBatteryWidget() observeClickListener() observeSwipeTouchListener() + setupHostedWidgets() } private fun initializeInjectedDependencies() { @@ -119,6 +131,10 @@ class WidgetFragment : Fragment(), 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) @@ -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() { super.onResume() context.registerReceiver(batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/utils/Constants.kt b/app/src/main/java/com/github/droidworksstudio/launcher/utils/Constants.kt index b5d4e0b..d252019 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/utils/Constants.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/utils/Constants.kt @@ -76,6 +76,8 @@ object Constants { const val DISABLE_ANIMATIONS = "DISABLE_ANIMATIONS" const val SHOW_NOTIFICATION_DOTS = "SHOW_NOTIFICATION_DOTS" 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_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT" diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/viewmodel/PreferenceViewModel.kt b/app/src/main/java/com/github/droidworksstudio/launcher/viewmodel/PreferenceViewModel.kt index 45ab7c7..a9179c5 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/viewmodel/PreferenceViewModel.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/viewmodel/PreferenceViewModel.kt @@ -51,6 +51,7 @@ class PreferenceViewModel @Inject constructor( private val disableAnimationsLiveData: MutableLiveData = MutableLiveData() val showNotificationDotsLiveData: MutableLiveData = MutableLiveData() val showCurrentWeatherLiveData: MutableLiveData = MutableLiveData() + val showHomeWidgetsLiveData: MutableLiveData = MutableLiveData() private val appGroupPaddingSizeLiveData: MutableLiveData = MutableLiveData() private val appPaddingSizeLiveData: MutableLiveData = MutableLiveData() @@ -309,6 +310,11 @@ class PreferenceViewModel @Inject constructor( showCurrentWeatherLiveData.postValue((preferenceHelper.showCurrentWeather)) } + fun setShowHomeWidgets(showHomeWidgets: Boolean) { + preferenceHelper.showHomeWidgets = showHomeWidgets + showHomeWidgetsLiveData.postValue((preferenceHelper.showHomeWidgets)) + } + fun setAppLanguage(appLanguage: Constants.Language) { preferenceHelper.appLanguage = appLanguage appLanguageLiveData.postValue((preferenceHelper.appLanguage)) diff --git a/app/src/main/res/layout/fragment_settings_features.xml b/app/src/main/res/layout/fragment_settings_features.xml index 920d0fe..b497fd6 100644 --- a/app/src/main/res/layout/fragment_settings_features.xml +++ b/app/src/main/res/layout/fragment_settings_features.xml @@ -269,6 +269,37 @@ tools:ignore="TouchTargetSizeCheck" /> + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0dd18f2..3c1bddc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -112,7 +112,16 @@ Lock Settings Disable Animations Notification Dots + App widgets Cannot open notification access settings. + Add widget + Add widget + Remove widget + Remove this widget? + Remove + Widget removed + No widget providers installed + This widget cannot be added right now. Size Color