feat: host app widgets on the widgets screen (K-9 unread widget)
This commit is contained in:
54
CLAUDE.md
54
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 +
|
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user