Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44d7e81801 | ||
|
|
553d1802c7 | ||
|
|
a42f913875 | ||
|
|
1d5a568218 | ||
|
|
daa1748858 | ||
|
|
09cc6a557d |
@@ -3,7 +3,7 @@
|
|||||||
Quick reference for agents working on this fork (Gitea: `jonas/EasyLauncher`,
|
Quick reference for agents working on this fork (Gitea: `jonas/EasyLauncher`,
|
||||||
installed on the Unihertz Titan 2 Elite as `app.easy.launcher`).
|
installed on the Unihertz Titan 2 Elite as `app.easy.launcher`).
|
||||||
|
|
||||||
**PROJECT.md in ~/titan2-elite is the source of truth for the phone itself.**
|
**CLAUDE.md in ~/titan2-elite is the source of truth for the phone itself.**
|
||||||
|
|
||||||
## Golden rules
|
## Golden rules
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export JAVA_HOME=~/jdk21 # Gradle 8.13, compileSdk/targetSdk 36, minSdk 2
|
|||||||
## Release flow (see git history for the v0.3.x pattern)
|
## Release flow (see git history for the v0.3.x pattern)
|
||||||
|
|
||||||
1. Bump `versionCode`/`versionName` in `app/build.gradle.kts`
|
1. Bump `versionCode`/`versionName` in `app/build.gradle.kts`
|
||||||
(currently 35 / 0.3.5).
|
(currently 38 / 0.3.8).
|
||||||
2. Build both flavors, then sign each:
|
2. Build both flavors, then sign each:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -46,9 +46,9 @@ PASS=$(cat ~/android-keystores/easylauncher-release.pass)
|
|||||||
~/android-sdk/build-tools/36.0.0/apksigner sign --v4-signing-enabled true \
|
~/android-sdk/build-tools/36.0.0/apksigner sign --v4-signing-enabled true \
|
||||||
--ks ~/android-keystores/easylauncher-release.jks --ks-key-alias easylauncher \
|
--ks ~/android-keystores/easylauncher-release.jks --ks-key-alias easylauncher \
|
||||||
--ks-pass "pass:$PASS" --key-pass "pass:$PASS" \
|
--ks-pass "pass:$PASS" --key-pass "pass:$PASS" \
|
||||||
--out dist/EasyLauncher-Internet-v0.3.5-Signed.apk \
|
--out dist/EasyLauncher-Internet-v0.3.8-Signed.apk \
|
||||||
app/build/outputs/apk/withInternet/release/app.easy.launcher_v0.3.5-Release.apk
|
app/build/outputs/apk/withInternet/release/app.easy.launcher_v0.3.7-Release.apk
|
||||||
# repeat for withoutInternet -> dist/EasyLauncher-v0.3.5-Signed.apk
|
# repeat for withoutInternet -> dist/EasyLauncher-v0.3.8-Signed.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
3. `apksigner verify --print-certs` should show the keystore SHA-256.
|
3. `apksigner verify --print-certs` should show the keystore SHA-256.
|
||||||
@@ -87,23 +87,54 @@ Prefs live in `/data/data/app.easy.launcher/shared_prefs/`. To change them:
|
|||||||
Users must grant notification access; the "Notification Dots" settings toggle
|
Users must grant notification access; the "Notification Dots" settings toggle
|
||||||
opens `ACTION_NOTIFICATION_LISTENER_SETTINGS` when missing.
|
opens `ACTION_NOTIFICATION_LISTENER_SETTINGS` when missing.
|
||||||
- Badge semantics: per package the service sums `Notification.number` (or 1
|
- Badge semantics: per package the service sums `Notification.number` (or 1
|
||||||
when unset) over COUNTED notifications — same as AOSP Launcher3. Only
|
when unset) over COUNTED notifications — same as AOSP Launcher3. Counted
|
||||||
swipe-away, non-ongoing notifications are counted (music/FGS excluded).
|
= clearable (swipe-away), NON-ongoing, NON group-summary, userId >= 0.
|
||||||
|
Group summaries and USER_ALL (-1) records are excluded (verified with
|
||||||
|
`cmd notification post`, see the log tag `BadgeService` — `publish counts=`
|
||||||
|
lines). Re-posts overwrite the stored entry (in-place `Notification.number`
|
||||||
|
updates, e.g. K-9 unread count), and a notification that becomes
|
||||||
|
non-counted is dropped.
|
||||||
|
- The dot is a plain red circle (NO white outline), drawn tangent to the
|
||||||
|
icon's top-right corner so it is never clipped (`NotificationDotHelper`).
|
||||||
|
- NOTE: the dot follows ACTIVE notifications (AOSP/Pixel behavior). Apps that
|
||||||
|
cancel their notification when opened — K-9 Mail does this on every open —
|
||||||
|
make the dot disappear even when unread mail remains. There is NO Android
|
||||||
|
API to read an app's unread count (K-9 v22 exposes no provider and no
|
||||||
|
AccountManager accounts; counts live in its private Room DBs). This is
|
||||||
|
standard Android behavior, not a bug in this launcher.
|
||||||
|
- Pref writes: `SHOW_NOTIFICATION_DOTS` must NOT be re-pushed from
|
||||||
|
`observeUserInterfaceSettings` — the other `setShowX` calls do write prefs,
|
||||||
|
and adding this one silently clobbered an on-disk `true` back to `false`
|
||||||
|
at startup (in-memory cache won). HomeFragment only observes
|
||||||
|
`showNotificationDotsLiveData` to rebind visible rows on toggle.
|
||||||
- Grant from adb:
|
- Grant from adb:
|
||||||
`cmd notification allow_listener app.easy.launcher/com.github.droidworksstudio.launcher.service.NotificationBadgeService`
|
`cmd notification allow_listener app.easy.launcher/com.github.droidworksstudio.launcher.service.NotificationBadgeService`
|
||||||
- Test notification: `cmd notification post -t 'Title' tag 'body'` — posts as
|
- End-to-end validation trick (the shell can't spoof other packages): run
|
||||||
`com.android.shell`, which has no launcher icon, so no dot is visible; verify
|
Termux's termux-notification as the user that owns com.termux.api (has a
|
||||||
visually with a real app notification instead.
|
launcher icon + posts a clearable notification):
|
||||||
|
```
|
||||||
|
adb shell "su -c 'su 10261 -c \"PATH=/data/data/com.termux/files/usr/bin:\$PATH \
|
||||||
|
termux-notification --id reddot-test --title RedDotTest --content Clearable\"'"
|
||||||
|
```
|
||||||
|
Expect a `BadgeService: publish counts={... 0/com.termux.api=1}` log and a
|
||||||
|
red dot on the Termux:API icon in the drawer. Verify pixels with
|
||||||
|
`python3`/PIL (dot = pure red, top-right of the icon box).
|
||||||
- Counts live in `NotificationBadgeService.notificationCounts`
|
- Counts live in `NotificationBadgeService.notificationCounts`
|
||||||
(StateFlow, key `"userId/packageName"`); fragments rebind visible rows.
|
(StateFlow, key `"userId/packageName"`); fragments rebind visible rows.
|
||||||
|
|
||||||
### Home current-weather element (MET/Yr)
|
### Home current-weather element (MET/Yr)
|
||||||
|
|
||||||
- Source: `https://api.met.no/weatherapi/locationforecast/2.0/compact` — **no API
|
- Fetched via the **shared `met-weather` library** (`dk.haugesenspil:met-weather`
|
||||||
key**, but REQUIRES a descriptive `User-Agent` header (see `MetApiService`).
|
from the Gitea maven registry, source in `~/sources/met-weather`):
|
||||||
Verified working from PC and phone network.
|
`MetWeatherClient(`met-weather 0.1.0`)` hits
|
||||||
- `AppHelper.fetchMetWeather(context, lat, lon)` returns Celsius (rounded) +
|
`https://api.met.no/weatherapi/locationforecast/2.0/compact` — **no API
|
||||||
MET `symbol_code`; 15-minute cache in `met_weather_prefs`.
|
key**, but REQUIRES a descriptive `User-Agent` header (passed in
|
||||||
|
`AppHelper.MET_USER_AGENT`). Same library feeds `met-weather-provider` and
|
||||||
|
therefore the GadgetBridge/Skagen watch.
|
||||||
|
- `AppHelper.fetchMetWeather(context, lat, lon)` (suspend) returns Celsius
|
||||||
|
(rounded) + MET `symbol_code` via `MetWeatherClient`; 15-minute cache in
|
||||||
|
`met_weather_prefs`. Bump `dk.haugesenspil:met-weather` version here when the
|
||||||
|
library is updated.
|
||||||
- The icon is a **Nerd Font weather glyph** (`nf-weather-*`, U+E300+ PUA),
|
- The icon is a **Nerd Font weather glyph** (`nf-weather-*`, U+E300+ PUA),
|
||||||
mapped from the symbol code in `MetSymbolMapper`. It renders with the bundled
|
mapped from the symbol code in `MetSymbolMapper`. It renders with the bundled
|
||||||
`R.font.jetbrains_mono_nf_weather` — a ~95KB fontTools subset of
|
`R.font.jetbrains_mono_nf_weather` — a ~95KB fontTools subset of
|
||||||
@@ -119,6 +150,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
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# Task: Add home-screen app-widget hosting to EasyLauncher
|
||||||
|
|
||||||
|
You are extending the EasyLauncher fork at `~/sources/EasyLauncher` (branch `main`).
|
||||||
|
Goal: let the user place **real Android app widgets** (in particular K-9 Mail's
|
||||||
|
"K-9 Unread" widget) on the launcher home screen, rendered live and persisted
|
||||||
|
across reboots.
|
||||||
|
|
||||||
|
Read `CLAUDE.md` in the repo root first — it is the fork's operating manual
|
||||||
|
(build, signing, on-device testing, quirks). Follow it.
|
||||||
|
|
||||||
|
## Why (background)
|
||||||
|
|
||||||
|
The launcher shows a red dot on app icons for active notifications
|
||||||
|
(`NotificationBadgeService`). K-9 Mail cancels its new-mail notification when
|
||||||
|
the app is opened, so the dot disappears even when unread mail remains. There
|
||||||
|
is no Android API to read another app's unread count. The clean, official
|
||||||
|
workaround is K-9's own **"K-9 Unread" widget**, which shows a persistent
|
||||||
|
unread count that only goes to zero when the mail is read. EasyLauncher
|
||||||
|
currently cannot host third-party app widgets (its "Widgets" page,
|
||||||
|
`WidgetFragment.kt`, is only self-drawn battery/clock widgets). This task adds
|
||||||
|
real app-widget hosting.
|
||||||
|
|
||||||
|
Scope guard: do NOT implement root-based database reading, IMAP polling, or
|
||||||
|
changes to K-9. Widget hosting only.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- Repo: `~/sources/EasyLauncher`, branch `main`, origin = Gitea `jonas/EasyLauncher`.
|
||||||
|
No `upstream` remote. Commit locally with conventional messages (see git
|
||||||
|
history: `feat: ...`, `fix: ...`, `docs: ...`). Do not push, tag, or create
|
||||||
|
a Gitea release unless the user asks.
|
||||||
|
- Stack: Kotlin, minSdk 24, compileSdk/targetSdk 36, Java 17, Hilt (kapt),
|
||||||
|
Room, ViewBinding (+ DataBinding enabled), two product flavors
|
||||||
|
(`withInternet` / `withoutInternet`), R8 enabled in release.
|
||||||
|
- Build (offline, JDK 21):
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=~/jdk21
|
||||||
|
./gradlew :app:compileWithInternetReleaseKotlin :app:compileWithoutInternetReleaseKotlin --offline
|
||||||
|
./gradlew :app:assembleWithInternetRelease :app:assembleWithoutInternetRelease --offline
|
||||||
|
```
|
||||||
|
Outputs: `app/build/outputs/apk/{withInternet,withoutInternet}/release/`.
|
||||||
|
- Signing (for installing on the phone):
|
||||||
|
```bash
|
||||||
|
PASS=$(cat ~/android-keystores/easylauncher-release.pass)
|
||||||
|
~/android-sdk/build-tools/36.0.0/apksigner sign --v4-signing-enabled true \
|
||||||
|
--ks ~/android-keystores/easylauncher-release.jks --ks-key-alias easylauncher \
|
||||||
|
--ks-pass "pass:$PASS" --key-pass "pass:$PASS" \
|
||||||
|
--out /tmp/EasyLauncher-widgethost.apk \
|
||||||
|
app/build/outputs/apk/withInternet/release/app.easy.launcher_v0.3.6-Release.apk
|
||||||
|
```
|
||||||
|
Do NOT bump versionCode/versionName (current 36 / 0.3.6) and do NOT write
|
||||||
|
into `dist/` — this is a feature build, not a release. Installing with the
|
||||||
|
same versionCode works: `adb install -r /tmp/EasyLauncher-widgethost.apk`.
|
||||||
|
- Phone: Unihertz Titan 2 Elite, adb serial `TITAN20000043119`, rooted
|
||||||
|
(Magisk). `app.easy.launcher` (signed release) is installed and is the
|
||||||
|
default home app. K-9 Mail (`com.fsck.k9`) is installed with 3 accounts.
|
||||||
|
Device facts: after a reboot the phone is CE-locked until the user enters
|
||||||
|
the PIN — if something "doesn't work" right after a reboot, ask the user to
|
||||||
|
unlock before debugging.
|
||||||
|
- Screenshots: `adb exec-out screencap -p > /tmp/s.png`; UI dump:
|
||||||
|
`adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml`.
|
||||||
|
Logs: `adb logcat | grep -i widgethost`.
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
### 1. Widget host manager (the core plumbing)
|
||||||
|
|
||||||
|
Create a Hilt `@Singleton` manager (e.g.
|
||||||
|
`app/src/main/java/com/github/droidworksstudio/launcher/service/WidgetHostManager.kt`,
|
||||||
|
or `helper/` if that fits better — follow existing package conventions) that
|
||||||
|
wraps:
|
||||||
|
|
||||||
|
- `AppWidgetHost(context, hostId)` with a unique constant hostId.
|
||||||
|
- `AppWidgetManager.getInstance(context)`.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- **`startListening()` / `stopListening()`** — delegate to `AppWidgetHost`.
|
||||||
|
- **Restore placed widgets** — re-create `AppWidgetHostView`s from persisted
|
||||||
|
widget ids after process death / reboot:
|
||||||
|
- For each stored `(appWidgetId, component)`:
|
||||||
|
- `appWidgetManager.getAppWidgetInfo(id)`; if null (unbound/removed),
|
||||||
|
drop the entry (`host.deleteAppWidget(id)` is not needed for missing
|
||||||
|
info; just clear the record).
|
||||||
|
- Else `val view = host.createView(context, id, info)` and add to the
|
||||||
|
home widget container. `createView` binds the widget; the view updates
|
||||||
|
automatically while the host is listening.
|
||||||
|
- **`requestAddWidget(component, callback)`** — the add flow:
|
||||||
|
1. `val id = host.allocateAppWidgetId()`
|
||||||
|
2. `if (!appWidgetManager.bindAppWidgetIdIfAllowed(id, component))` →
|
||||||
|
the app is not the active launcher; notify the user
|
||||||
|
("Set Easy Launcher as the default home app") and abort.
|
||||||
|
3. If the provider has a configure activity
|
||||||
|
(`providerInfo.configure != null`), launch it with
|
||||||
|
`appWidgetManager.startAppWidgetConfigureActivityForResult(activity, id,
|
||||||
|
REQUEST_ADD_WIDGET, Bundle())` (4-arg version, API 17+, fine for
|
||||||
|
minSdk 24; verify the exact signature against the local SDK — compileSdk
|
||||||
|
36 sources are available). Resume on `onActivityResult`: `RESULT_OK` →
|
||||||
|
continue; otherwise `host.deleteAppWidget(id)` and abort.
|
||||||
|
4. Create and return the bound `AppWidgetHostView`
|
||||||
|
(`host.createView(context, id, info)`).
|
||||||
|
- **`removeWidget(view, id)`** — remove view from container,
|
||||||
|
`host.deleteAppWidget(id)`, clear the persisted record.
|
||||||
|
- **Persistence** — store placed widgets in SharedPreferences (follow
|
||||||
|
`PreferenceHelper` patterns; JSON string of `id|component` pairs, key e.g.
|
||||||
|
`HOSTED_WIDGETS`). Room is acceptable but prefs match the fork's style.
|
||||||
|
- Keep it dependency-light and testable. Log with tag `WidgetHost`.
|
||||||
|
|
||||||
|
### 2. Home screen widget area
|
||||||
|
|
||||||
|
`fragment_home.xml` has a vertical `mainView` LinearLayout:
|
||||||
|
`blockView` (battery/clock/date/weather) on top, `appListTouchArea`
|
||||||
|
(RecyclerView grid) below.
|
||||||
|
|
||||||
|
- Insert a full-width widget container between `blockView` and
|
||||||
|
`appListTouchArea`, e.g. a `FrameLayout` (or vertical `LinearLayout` for
|
||||||
|
stacking) `android:id="@+id/widgetHostArea"`, `visibility="gone"` when no
|
||||||
|
widgets are placed. Keep margins consistent with the rest of the screen
|
||||||
|
(20dp horizontal, mirroring `blockView`).
|
||||||
|
- Wire in `HomeFragment.kt`:
|
||||||
|
- `onStart`: `widgetHostManager.startListening()`; restore placed widgets
|
||||||
|
into the container. `onStop`: `stopListening()`.
|
||||||
|
- Handle the add result (`onActivityResult` REQUEST_ADD_WIDGET) by inserting
|
||||||
|
the returned `AppWidgetHostView`, then persist and show the container.
|
||||||
|
- Removing: long-press a hosted widget → confirm dialog → remove.
|
||||||
|
- "Add widget" affordance when the feature is enabled: a small "+" in the
|
||||||
|
widget area when empty (and/or a settings entry — see below). Do not
|
||||||
|
break existing swipe/gesture handlers (`touchArea`, `appListTouchArea`
|
||||||
|
forwards vertical drags — the new area sits outside `appListTouchArea` so
|
||||||
|
no conflict, but verify touches on the widget still work).
|
||||||
|
- If the widget area is `gone` when empty, make sure the home grid layout
|
||||||
|
does not jump when the first widget is added.
|
||||||
|
|
||||||
|
### 3. Widget picker
|
||||||
|
|
||||||
|
A bottom sheet or `AlertDialog` listing installed widget providers:
|
||||||
|
- `appWidgetManager.getInstalledProviders()` (deprecated on API 33+ — use the
|
||||||
|
profile variant `getInstalledProvidersForProfile(UserHandle.CURRENT)` on
|
||||||
|
API 33+, guard with `Build.VERSION`). The app already has
|
||||||
|
`QUERY_ALL_PACKAGES`, so all providers are visible.
|
||||||
|
- Each row: `providerInfo.loadIcon(context, density)` + label; sort by label.
|
||||||
|
- Tapping a row → `widgetHostManager.requestAddWidget(...)` flow above.
|
||||||
|
- Opening the picker: add a settings entry (see 4). Optionally also a
|
||||||
|
long-press on empty home space if the launcher has no competing handler —
|
||||||
|
check first; if home long-press is already used, use settings only.
|
||||||
|
|
||||||
|
### 4. Settings
|
||||||
|
|
||||||
|
Follow the existing "Notification Dots" toggle pattern
|
||||||
|
(`SHOW_NOTIFICATION_DOTS` in `PreferenceHelper` +
|
||||||
|
`SettingsFeaturesFragment`):
|
||||||
|
- New pref `SHOW_HOME_WIDGETS` (default OFF).
|
||||||
|
- Toggle: "Home screen widgets".
|
||||||
|
- When ON, expose "Add widget" (opens the picker) — e.g. a secondary row or
|
||||||
|
the "+" on the home widget area.
|
||||||
|
- Add string resources in `values/strings.xml` only (the app is on Crowdin —
|
||||||
|
do not invent translations in other locales).
|
||||||
|
- Pref caveat from CLAUDE.md: don't re-push the value from
|
||||||
|
`observeUserInterfaceSettings` in a way that clobbers the on-disk value
|
||||||
|
(see the `SHOW_NOTIFICATION_DOTS` note).
|
||||||
|
|
||||||
|
### 5. Permissions
|
||||||
|
|
||||||
|
None new. Do NOT add `android.permission.BIND_APPWIDGET` (host role does not
|
||||||
|
need it). `QUERY_ALL_PACKAGES` already covers provider enumeration.
|
||||||
|
|
||||||
|
### 6. Documentation
|
||||||
|
|
||||||
|
Update `CLAUDE.md` in the repo root with a short section on the widget host
|
||||||
|
(service/manager name, pref key, how to place/remove a widget via adb if
|
||||||
|
useful, known quirks), matching the fork's existing documentation style.
|
||||||
|
|
||||||
|
## Acceptance criteria (verify on the phone)
|
||||||
|
|
||||||
|
1. Both flavors compile offline.
|
||||||
|
2. Signed `withInternet` APK installs in place over the current
|
||||||
|
`app.easy.launcher` (same signature, same versionCode 36) — `adb install -r`.
|
||||||
|
3. Settings → Features shows the new "Home screen widgets" toggle; enabling
|
||||||
|
it surfaces "Add widget".
|
||||||
|
4. The picker lists "K-9 Unread" (and other installed widgets). If K-9's
|
||||||
|
unread widget is missing, check `adb shell dumpsys package com.fsck.k9 |
|
||||||
|
grep -B2 -A6 UnreadWidgetProvider` — K-9's widget receivers are enabled
|
||||||
|
programmatically (`android:enabled` from a manifest bool); if disabled,
|
||||||
|
find the K-9 setting that enables home-screen widgets (ask the user if
|
||||||
|
unclear) or `adb shell pm enable
|
||||||
|
com.fsck.k9/com.fsck.k9.provider.UnreadWidgetProvider`.
|
||||||
|
5. Placing the K-9 Unread widget runs K-9's configuration (account picker),
|
||||||
|
then the widget renders on the home screen showing a live unread count
|
||||||
|
(currently 11 unread across the user's 3 accounts — a per-account widget
|
||||||
|
shows that account's count). Reading mail in K-9 decreases it; the dot
|
||||||
|
question is irrelevant here because the widget keeps counting until 0.
|
||||||
|
6. Widget survives: app restart (force-stop + relaunch via HOME), and a phone
|
||||||
|
reboot (after the user re-enters their PIN — CE-lock).
|
||||||
|
7. Long-press → remove works; after removal the area is hidden again and the
|
||||||
|
widget id is gone from prefs.
|
||||||
|
8. Regression: notification dots still appear (see CLAUDE.md validation
|
||||||
|
trick), home grid/weather/drawer unchanged, no crash on rotation.
|
||||||
|
9. Screenshot proof: `adb exec-out screencap -p > /tmp/widget-proof.png`
|
||||||
|
showing the K-9 Unread widget on the home screen. Keep the file and report
|
||||||
|
its path.
|
||||||
|
10. No leaked `AppWidgetHostView`s: `stopListening` on `onStop`, views
|
||||||
|
removed on removal. Watch `logcat` for host-related warnings.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
- Working feature per above, committed as `feat: host app widgets on home
|
||||||
|
screen (K-9 unread widget)` (or similar) — commit locally, do not push.
|
||||||
|
- `CLAUDE.md` updated.
|
||||||
|
- Verification evidence: build logs, screenshot path, list of things tested
|
||||||
|
on the phone and their outcome.
|
||||||
|
|
||||||
|
Report back concisely: what you built (files + key decisions), what you
|
||||||
|
verified on the phone, and anything the user must do (e.g. K-9 widget
|
||||||
|
setting, PIN unlock after reboot).
|
||||||
@@ -19,8 +19,8 @@ android {
|
|||||||
applicationId = "app.easy.launcher"
|
applicationId = "app.easy.launcher"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 36
|
versionCode = 38
|
||||||
versionName = "0.3.6"
|
versionName = "0.3.8"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
manifestPlaceholders["internetPermission"] = "android.permission.INTERNET"
|
manifestPlaceholders["internetPermission"] = "android.permission.INTERNET"
|
||||||
@@ -187,6 +187,7 @@ dependencies {
|
|||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation(libs.retrofit)
|
implementation(libs.retrofit)
|
||||||
implementation(libs.converter.gson)
|
implementation(libs.converter.gson)
|
||||||
|
implementation("dk.haugesenspil:met-weather:0.1.0")
|
||||||
implementation(libs.constraintlayout)
|
implementation(libs.constraintlayout)
|
||||||
implementation(libs.navigation.fragment.ktx)
|
implementation(libs.navigation.fragment.ktx)
|
||||||
implementation(libs.navigation.ui.ktx)
|
implementation(libs.navigation.ui.ktx)
|
||||||
|
|||||||
Vendored
+3
@@ -26,5 +26,8 @@
|
|||||||
-keep class com.github.droidworksstudio.launcher.helper.weather.Main { *; }
|
-keep class com.github.droidworksstudio.launcher.helper.weather.Main { *; }
|
||||||
-keep class com.github.droidworksstudio.launcher.helper.weather.Weather { *; }
|
-keep class com.github.droidworksstudio.launcher.helper.weather.Weather { *; }
|
||||||
|
|
||||||
|
# Keep the shared met-weather library classes (Gson DTOs must keep field names)
|
||||||
|
-keep class dk.haugesenspil.metweather.** { *; }
|
||||||
|
|
||||||
# Keep all model classes and their fields that Gson might use for serialization/deserialization
|
# Keep all model classes and their fields that Gson might use for serialization/deserialization
|
||||||
-keep class com.github.droidworksstudio.launcher.helper.weather.** { *; }
|
-keep class com.github.droidworksstudio.launcher.helper.weather.** { *; }
|
||||||
|
|||||||
@@ -33,16 +33,15 @@ import com.github.droidworksstudio.launcher.R
|
|||||||
import com.github.droidworksstudio.launcher.accessibility.ActionService
|
import com.github.droidworksstudio.launcher.accessibility.ActionService
|
||||||
import com.github.droidworksstudio.launcher.data.dao.AppInfoDAO
|
import com.github.droidworksstudio.launcher.data.dao.AppInfoDAO
|
||||||
import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
||||||
import com.github.droidworksstudio.launcher.helper.weather.MetForecastResponse
|
|
||||||
import com.github.droidworksstudio.launcher.helper.weather.WeatherResponse
|
import com.github.droidworksstudio.launcher.helper.weather.WeatherResponse
|
||||||
import com.github.droidworksstudio.launcher.utils.Constants
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
import com.github.droidworksstudio.launcher.utils.MetApiService
|
|
||||||
import com.github.droidworksstudio.launcher.utils.MetSymbolMapper
|
import com.github.droidworksstudio.launcher.utils.MetSymbolMapper
|
||||||
import com.github.droidworksstudio.launcher.utils.WeatherApiService
|
import com.github.droidworksstudio.launcher.utils.WeatherApiService
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.JsonSyntaxException
|
import com.google.gson.JsonSyntaxException
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import com.google.gson.stream.JsonReader
|
import com.google.gson.stream.JsonReader
|
||||||
|
import dk.haugesenspil.metweather.MetWeatherClient
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@@ -57,6 +56,12 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
class AppHelper @Inject constructor() {
|
class AppHelper @Inject constructor() {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** MET requires a descriptive User-Agent identifying the app + contact. */
|
||||||
|
const val MET_USER_AGENT = "app.easy.launcher (https://gitea.haugesenspil.dk/jonas/EasyLauncher)"
|
||||||
|
private const val FOUR_HOURS_MILLIS = 4L * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("WrongConstant", "PrivateApi")
|
@SuppressLint("WrongConstant", "PrivateApi")
|
||||||
fun expandNotificationDrawer(context: Context) {
|
fun expandNotificationDrawer(context: Context) {
|
||||||
try {
|
try {
|
||||||
@@ -558,16 +563,23 @@ class AppHelper @Inject constructor() {
|
|||||||
data class CachedWeatherData(val timestamp: Long, val weatherResponse: WeatherResponse)
|
data class CachedWeatherData(val timestamp: Long, val weatherResponse: WeatherResponse)
|
||||||
|
|
||||||
sealed class MetWeatherResult {
|
sealed class MetWeatherResult {
|
||||||
data class Success(val temperature: Int, val symbolCode: String) : MetWeatherResult()
|
data class Success(
|
||||||
|
val temperature: Int,
|
||||||
|
val symbolCode: String,
|
||||||
|
val temperature4h: Int? = null,
|
||||||
|
val symbol4h: String? = null,
|
||||||
|
) : MetWeatherResult()
|
||||||
|
|
||||||
data class Failure(val errorMessage: String) : MetWeatherResult()
|
data class Failure(val errorMessage: String) : MetWeatherResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the current temperature and symbol from the MET/Yr API
|
* Fetches the current temperature and symbol from the MET/Yr API via the
|
||||||
* (api.met.no locationforecast). No API key needed - only a
|
* shared `met-weather` library (api.met.no locationforecast). No API key
|
||||||
* descriptive User-Agent header. Result is cached for 15 minutes.
|
* needed - only a descriptive User-Agent header. Result is cached for
|
||||||
|
* 15 minutes.
|
||||||
*/
|
*/
|
||||||
fun fetchMetWeather(
|
suspend fun fetchMetWeather(
|
||||||
context: Context,
|
context: Context,
|
||||||
latitude: Float,
|
latitude: Float,
|
||||||
longitude: Float,
|
longitude: Float,
|
||||||
@@ -584,46 +596,43 @@ class AppHelper @Inject constructor() {
|
|||||||
return cached.second
|
return cached.second
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return try {
|
||||||
val retrofit = Retrofit.Builder()
|
val weather = MetWeatherClient(MET_USER_AGENT)
|
||||||
.baseUrl("https://api.met.no/weatherapi/locationforecast/2.0/")
|
.fetch(latitude.toDouble(), longitude.toDouble())
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
val now = System.currentTimeMillis()
|
||||||
.build()
|
val plus4 = weather.hourly.minByOrNull {
|
||||||
|
kotlin.math.abs(it.epochMillis - (now + FOUR_HOURS_MILLIS))
|
||||||
val service = retrofit.create(MetApiService::class.java)
|
|
||||||
val response = service.getCompact(latitude.toDouble(), longitude.toDouble()).execute()
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
val body = response.body()
|
|
||||||
val timeseries = body?.properties?.timeseries
|
|
||||||
val first = timeseries?.firstOrNull()
|
|
||||||
if (first != null) {
|
|
||||||
val temperature = first.data.instant.details.airTemperature.roundToInt()
|
|
||||||
val symbolCode = first.data.next1Hours?.summary?.symbolCode
|
|
||||||
?: first.data.next6Hours?.summary?.symbolCode
|
|
||||||
?: "cloudy"
|
|
||||||
val result = MetWeatherResult.Success(temperature, symbolCode)
|
|
||||||
context.cacheMetWeather(result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
return MetWeatherResult.Failure("Empty forecast")
|
|
||||||
} else {
|
|
||||||
return MetWeatherResult.Failure("MET API error: ${response.code()}")
|
|
||||||
}
|
}
|
||||||
} catch (e: UnknownHostException) {
|
val result = MetWeatherResult.Success(
|
||||||
Log.e("AppHelper", "MET fetch: unknown host api.met.no", e)
|
temperature = weather.temperatureC.roundToInt(),
|
||||||
return MetWeatherResult.Failure("Unknown host: api.met.no")
|
symbolCode = weather.symbolCode,
|
||||||
|
temperature4h = plus4?.temperatureC?.roundToInt(),
|
||||||
|
symbol4h = plus4?.symbolCode,
|
||||||
|
)
|
||||||
|
context.cacheMetWeather(result)
|
||||||
|
result
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("AppHelper", "MET fetch failed", e)
|
Log.e("AppHelper", "MET fetch failed", e)
|
||||||
return MetWeatherResult.Failure(e.message ?: "MET fetch failed")
|
MetWeatherResult.Failure(e.message ?: "MET fetch failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the home-screen weather text: nerd-font weather glyph followed
|
* Builds the home-screen weather text: the current nerd-font weather glyph
|
||||||
* by the temperature in Celsius, e.g. "\uE312 16°".
|
* and Celsius value, plus the reading ~4 hours out when available, e.g.
|
||||||
|
* "\uE302 16° → \uE31D 18°".
|
||||||
*/
|
*/
|
||||||
fun buildCurrentWeatherText(temperature: Int, symbolCode: String): String {
|
fun buildCurrentWeatherText(
|
||||||
return "${MetSymbolMapper.glyphFor(symbolCode)} $temperature°"
|
temperature: Int,
|
||||||
|
symbolCode: String,
|
||||||
|
temperature4h: Int? = null,
|
||||||
|
symbol4h: String? = null,
|
||||||
|
): String {
|
||||||
|
val current = "${MetSymbolMapper.glyphFor(symbolCode)} $temperature°"
|
||||||
|
if (temperature4h != null && symbol4h != null) {
|
||||||
|
return "$current → ${MetSymbolMapper.glyphFor(symbol4h)} $temperature4h°"
|
||||||
|
}
|
||||||
|
return current
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Context.cacheMetWeather(result: MetWeatherResult.Success) {
|
private fun Context.cacheMetWeather(result: MetWeatherResult.Success) {
|
||||||
@@ -632,6 +641,8 @@ class AppHelper @Inject constructor() {
|
|||||||
.putLong(Constants.MET_WEATHER_TIMESTAMP, System.currentTimeMillis())
|
.putLong(Constants.MET_WEATHER_TIMESTAMP, System.currentTimeMillis())
|
||||||
.putInt(Constants.MET_WEATHER_TEMPERATURE, result.temperature)
|
.putInt(Constants.MET_WEATHER_TEMPERATURE, result.temperature)
|
||||||
.putString(Constants.MET_WEATHER_SYMBOL, result.symbolCode)
|
.putString(Constants.MET_WEATHER_SYMBOL, result.symbolCode)
|
||||||
|
.putInt(Constants.MET_WEATHER_TEMPERATURE_4H, result.temperature4h ?: Int.MIN_VALUE)
|
||||||
|
.putString(Constants.MET_WEATHER_SYMBOL_4H, result.symbol4h ?: "")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,6 +652,14 @@ class AppHelper @Inject constructor() {
|
|||||||
if (timestamp == -1L) return null
|
if (timestamp == -1L) return null
|
||||||
val temperature = sharedPreferences.getInt(Constants.MET_WEATHER_TEMPERATURE, 0)
|
val temperature = sharedPreferences.getInt(Constants.MET_WEATHER_TEMPERATURE, 0)
|
||||||
val symbol = sharedPreferences.getString(Constants.MET_WEATHER_SYMBOL, "cloudy") ?: "cloudy"
|
val symbol = sharedPreferences.getString(Constants.MET_WEATHER_SYMBOL, "cloudy") ?: "cloudy"
|
||||||
return timestamp to MetWeatherResult.Success(temperature, symbol)
|
val temp4h = sharedPreferences.getInt(Constants.MET_WEATHER_TEMPERATURE_4H, Int.MIN_VALUE)
|
||||||
|
val symbol4h = sharedPreferences.getString(Constants.MET_WEATHER_SYMBOL_4H, "") ?: ""
|
||||||
|
val result = MetWeatherResult.Success(
|
||||||
|
temperature = temperature,
|
||||||
|
symbolCode = symbol,
|
||||||
|
temperature4h = temp4h.takeIf { it != Int.MIN_VALUE },
|
||||||
|
symbol4h = symbol4h.takeIf { it.isNotEmpty() },
|
||||||
|
)
|
||||||
|
return timestamp to result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -252,7 +256,7 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
|
|||||||
set(value) = prefs.edit().putString(Constants.LAUNCHER_FONT, value.name).apply()
|
set(value) = prefs.edit().putString(Constants.LAUNCHER_FONT, value.name).apply()
|
||||||
|
|
||||||
var swipeUpAction: Constants.Action
|
var swipeUpAction: Constants.Action
|
||||||
get() = loadAction(Constants.SWIPE_UP_ACTION, Constants.Action.ShowRecents)
|
get() = loadAction(Constants.SWIPE_UP_ACTION, Constants.Action.ShowWidgets)
|
||||||
set(value) = storeAction(Constants.SWIPE_UP_ACTION, value)
|
set(value) = storeAction(Constants.SWIPE_UP_ACTION, value)
|
||||||
|
|
||||||
var swipeDownAction: Constants.Action
|
var swipeDownAction: Constants.Action
|
||||||
|
|||||||
-46
@@ -1,46 +0,0 @@
|
|||||||
package com.github.droidworksstudio.launcher.helper.weather
|
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locationforecast 2.0 compact response (api.met.no) - only the fields the
|
|
||||||
* home-screen current-weather element needs.
|
|
||||||
*/
|
|
||||||
data class MetForecastResponse(
|
|
||||||
val properties: MetProperties
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MetProperties(
|
|
||||||
val timeseries: List<MetTimeSeries>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MetTimeSeries(
|
|
||||||
val time: String,
|
|
||||||
val data: MetData
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MetData(
|
|
||||||
val instant: MetInstant,
|
|
||||||
@SerializedName("next_1_hours")
|
|
||||||
val next1Hours: MetNextHours? = null,
|
|
||||||
@SerializedName("next_6_hours")
|
|
||||||
val next6Hours: MetNextHours? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MetInstant(
|
|
||||||
val details: MetDetails
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MetDetails(
|
|
||||||
@SerializedName("air_temperature")
|
|
||||||
val airTemperature: Double = 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MetNextHours(
|
|
||||||
val summary: MetSummary
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MetSummary(
|
|
||||||
@SerializedName("symbol_code")
|
|
||||||
val symbolCode: String = ""
|
|
||||||
)
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -393,7 +393,9 @@ class HomeFragment : Fragment(),
|
|||||||
is AppHelper.MetWeatherResult.Success -> {
|
is AppHelper.MetWeatherResult.Success -> {
|
||||||
binding.currentWeather.text = appHelper.buildCurrentWeatherText(
|
binding.currentWeather.text = appHelper.buildCurrentWeatherText(
|
||||||
result.temperature,
|
result.temperature,
|
||||||
result.symbolCode
|
result.symbolCode,
|
||||||
|
result.temperature4h,
|
||||||
|
result.symbol4h,
|
||||||
)
|
)
|
||||||
binding.currentWeather.visibility = View.VISIBLE
|
binding.currentWeather.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -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))
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ object Constants {
|
|||||||
const val MET_WEATHER_TIMESTAMP = "MET_WEATHER_TIMESTAMP"
|
const val MET_WEATHER_TIMESTAMP = "MET_WEATHER_TIMESTAMP"
|
||||||
const val MET_WEATHER_TEMPERATURE = "MET_WEATHER_TEMPERATURE"
|
const val MET_WEATHER_TEMPERATURE = "MET_WEATHER_TEMPERATURE"
|
||||||
const val MET_WEATHER_SYMBOL = "MET_WEATHER_SYMBOL"
|
const val MET_WEATHER_SYMBOL = "MET_WEATHER_SYMBOL"
|
||||||
|
const val MET_WEATHER_TEMPERATURE_4H = "MET_WEATHER_TEMPERATURE_4H"
|
||||||
|
const val MET_WEATHER_SYMBOL_4H = "MET_WEATHER_SYMBOL_4H"
|
||||||
const val WEATHER_RESPONSE = "WEATHER_RESPONSE"
|
const val WEATHER_RESPONSE = "WEATHER_RESPONSE"
|
||||||
const val WEATHER_UNITS = "WEATHER_UNITS"
|
const val WEATHER_UNITS = "WEATHER_UNITS"
|
||||||
const val LATITUDE = "LATITUDE"
|
const val LATITUDE = "LATITUDE"
|
||||||
@@ -76,6 +78,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"
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package com.github.droidworksstudio.launcher.utils
|
|
||||||
|
|
||||||
import com.github.droidworksstudio.launcher.helper.weather.MetForecastResponse
|
|
||||||
import retrofit2.Call
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.Headers
|
|
||||||
import retrofit2.http.Query
|
|
||||||
|
|
||||||
interface MetApiService {
|
|
||||||
|
|
||||||
@Headers(
|
|
||||||
"User-Agent: app.easy.launcher (https://gitea.haugesenspil.dk/jonas/EasyLauncher)"
|
|
||||||
)
|
|
||||||
@GET("compact")
|
|
||||||
fun getCompact(
|
|
||||||
@Query("lat") latitude: Double,
|
|
||||||
@Query("lon") longitude: Double
|
|
||||||
): Call<MetForecastResponse>
|
|
||||||
}
|
|
||||||
+6
@@ -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>
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -14,6 +14,7 @@ dependencyResolutionManagement {
|
|||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven(url = "https://jitpack.io")
|
maven(url = "https://jitpack.io")
|
||||||
|
maven(url = "https://gitea.haugesenspil.dk/api/packages/jonas/maven")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user