Compare commits
8 Commits
68ebe26f29
...
v0.3.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 44d7e81801 | |||
| 553d1802c7 | |||
| a42f913875 | |||
| 1d5a568218 | |||
| daa1748858 | |||
| 09cc6a557d | |||
| da5542690d | |||
| 9799d3057b |
114
CLAUDE.md
114
CLAUDE.md
@@ -3,7 +3,7 @@
|
||||
Quick reference for agents working on this fork (Gitea: `jonas/EasyLauncher`,
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
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:
|
||||
|
||||
```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 \
|
||||
--ks ~/android-keystores/easylauncher-release.jks --ks-key-alias easylauncher \
|
||||
--ks-pass "pass:$PASS" --key-pass "pass:$PASS" \
|
||||
--out dist/EasyLauncher-Internet-v0.3.5-Signed.apk \
|
||||
app/build/outputs/apk/withInternet/release/app.easy.launcher_v0.3.5-Release.apk
|
||||
# repeat for withoutInternet -> dist/EasyLauncher-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.7-Release.apk
|
||||
# repeat for withoutInternet -> dist/EasyLauncher-v0.3.8-Signed.apk
|
||||
```
|
||||
|
||||
3. `apksigner verify --print-certs` should show the keystore SHA-256.
|
||||
@@ -86,21 +86,55 @@ Prefs live in `/data/data/app.easy.launcher/shared_prefs/`. To change them:
|
||||
- Service: `.service.NotificationBadgeService` (NotificationListenerService).
|
||||
Users must grant notification access; the "Notification Dots" settings toggle
|
||||
opens `ACTION_NOTIFICATION_LISTENER_SETTINGS` when missing.
|
||||
- Badge semantics: per package the service sums `Notification.number` (or 1
|
||||
when unset) over COUNTED notifications — same as AOSP Launcher3. Counted
|
||||
= 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:
|
||||
`cmd notification allow_listener app.easy.launcher/com.github.droidworksstudio.launcher.service.NotificationBadgeService`
|
||||
- Test notification: `cmd notification post -t 'Title' tag 'body'` — posts as
|
||||
`com.android.shell`, which has no launcher icon, so no dot is visible; verify
|
||||
visually with a real app notification instead.
|
||||
- End-to-end validation trick (the shell can't spoof other packages): run
|
||||
Termux's termux-notification as the user that owns com.termux.api (has a
|
||||
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`
|
||||
(StateFlow, key `"userId/packageName"`); fragments rebind visible rows.
|
||||
|
||||
### Home current-weather element (MET/Yr)
|
||||
|
||||
- Source: `https://api.met.no/weatherapi/locationforecast/2.0/compact` — **no API
|
||||
key**, but REQUIRES a descriptive `User-Agent` header (see `MetApiService`).
|
||||
Verified working from PC and phone network.
|
||||
- `AppHelper.fetchMetWeather(context, lat, lon)` returns Celsius (rounded) +
|
||||
MET `symbol_code`; 15-minute cache in `met_weather_prefs`.
|
||||
- Fetched via the **shared `met-weather` library** (`dk.haugesenspil:met-weather`
|
||||
from the Gitea maven registry, source in `~/sources/met-weather`):
|
||||
`MetWeatherClient(`met-weather 0.1.0`)` hits
|
||||
`https://api.met.no/weatherapi/locationforecast/2.0/compact` — **no API
|
||||
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),
|
||||
mapped from the symbol code in `MetSymbolMapper`. It renders with the bundled
|
||||
`R.font.jetbrains_mono_nf_weather` — a ~95KB fontTools subset of
|
||||
@@ -116,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 +
|
||||
`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
|
||||
|
||||
- The accessibility-service dialog ("Please turn on accessibility service to
|
||||
|
||||
214
WIDGET_HOSTING_TASK.md
Normal file
214
WIDGET_HOSTING_TASK.md
Normal file
@@ -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"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 35
|
||||
versionName = "0.3.5"
|
||||
versionCode = 38
|
||||
versionName = "0.3.8"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders["internetPermission"] = "android.permission.INTERNET"
|
||||
@@ -187,6 +187,7 @@ dependencies {
|
||||
implementation(libs.material)
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.converter.gson)
|
||||
implementation("dk.haugesenspil:met-weather:0.1.0")
|
||||
implementation(libs.constraintlayout)
|
||||
implementation(libs.navigation.fragment.ktx)
|
||||
implementation(libs.navigation.ui.ktx)
|
||||
|
||||
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
@@ -26,5 +26,8 @@
|
||||
-keep class com.github.droidworksstudio.launcher.helper.weather.Main { *; }
|
||||
-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 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.data.dao.AppInfoDAO
|
||||
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.utils.Constants
|
||||
import com.github.droidworksstudio.launcher.utils.MetApiService
|
||||
import com.github.droidworksstudio.launcher.utils.MetSymbolMapper
|
||||
import com.github.droidworksstudio.launcher.utils.WeatherApiService
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.google.gson.stream.JsonReader
|
||||
import dk.haugesenspil.metweather.MetWeatherClient
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.math.roundToInt
|
||||
import retrofit2.Retrofit
|
||||
@@ -57,6 +56,12 @@ import javax.inject.Inject
|
||||
|
||||
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")
|
||||
fun expandNotificationDrawer(context: Context) {
|
||||
try {
|
||||
@@ -558,16 +563,23 @@ class AppHelper @Inject constructor() {
|
||||
data class CachedWeatherData(val timestamp: Long, val weatherResponse: WeatherResponse)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current temperature and symbol from the MET/Yr API
|
||||
* (api.met.no locationforecast). No API key needed - only a
|
||||
* descriptive User-Agent header. Result is cached for 15 minutes.
|
||||
* Fetches the current temperature and symbol from the MET/Yr API via the
|
||||
* shared `met-weather` library (api.met.no locationforecast). No API key
|
||||
* needed - only a descriptive User-Agent header. Result is cached for
|
||||
* 15 minutes.
|
||||
*/
|
||||
fun fetchMetWeather(
|
||||
suspend fun fetchMetWeather(
|
||||
context: Context,
|
||||
latitude: Float,
|
||||
longitude: Float,
|
||||
@@ -584,46 +596,43 @@ class AppHelper @Inject constructor() {
|
||||
return cached.second
|
||||
}
|
||||
|
||||
try {
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl("https://api.met.no/weatherapi/locationforecast/2.0/")
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
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()}")
|
||||
return try {
|
||||
val weather = MetWeatherClient(MET_USER_AGENT)
|
||||
.fetch(latitude.toDouble(), longitude.toDouble())
|
||||
val now = System.currentTimeMillis()
|
||||
val plus4 = weather.hourly.minByOrNull {
|
||||
kotlin.math.abs(it.epochMillis - (now + FOUR_HOURS_MILLIS))
|
||||
}
|
||||
} catch (e: UnknownHostException) {
|
||||
Log.e("AppHelper", "MET fetch: unknown host api.met.no", e)
|
||||
return MetWeatherResult.Failure("Unknown host: api.met.no")
|
||||
val result = MetWeatherResult.Success(
|
||||
temperature = weather.temperatureC.roundToInt(),
|
||||
symbolCode = weather.symbolCode,
|
||||
temperature4h = plus4?.temperatureC?.roundToInt(),
|
||||
symbol4h = plus4?.symbolCode,
|
||||
)
|
||||
context.cacheMetWeather(result)
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
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
|
||||
* by the temperature in Celsius, e.g. "\uE312 16°".
|
||||
* Builds the home-screen weather text: the current nerd-font weather glyph
|
||||
* and Celsius value, plus the reading ~4 hours out when available, e.g.
|
||||
* "\uE302 16° → \uE31D 18°".
|
||||
*/
|
||||
fun buildCurrentWeatherText(temperature: Int, symbolCode: String): String {
|
||||
return "${MetSymbolMapper.glyphFor(symbolCode)} $temperature°"
|
||||
fun buildCurrentWeatherText(
|
||||
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) {
|
||||
@@ -632,6 +641,8 @@ class AppHelper @Inject constructor() {
|
||||
.putLong(Constants.MET_WEATHER_TIMESTAMP, System.currentTimeMillis())
|
||||
.putInt(Constants.MET_WEATHER_TEMPERATURE, result.temperature)
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -641,6 +652,14 @@ class AppHelper @Inject constructor() {
|
||||
if (timestamp == -1L) return null
|
||||
val temperature = sharedPreferences.getInt(Constants.MET_WEATHER_TEMPERATURE, 0)
|
||||
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)
|
||||
set(value) = prefs.edit().putBoolean(Constants.SHOW_CURRENT_WEATHER, value).apply()
|
||||
|
||||
var showHomeWidgets: Boolean
|
||||
get() = prefs.getBoolean(Constants.SHOW_HOME_WIDGETS, false)
|
||||
set(value) = prefs.edit().putBoolean(Constants.SHOW_HOME_WIDGETS, value).apply()
|
||||
|
||||
var searchEngines: Constants.SearchEngines
|
||||
get() {
|
||||
return try {
|
||||
@@ -252,7 +256,7 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
|
||||
set(value) = prefs.edit().putString(Constants.LAUNCHER_FONT, value.name).apply()
|
||||
|
||||
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)
|
||||
|
||||
var swipeDownAction: Constants.Action
|
||||
|
||||
@@ -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 = ""
|
||||
)
|
||||
@@ -36,11 +36,21 @@ class NotificationBadgeService : NotificationListenerService() {
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||
if (!isCounted(sbn)) return
|
||||
synchronized(keyToEntry) {
|
||||
// A posted notification with an existing key is just an update.
|
||||
if (keyToEntry.containsKey(sbn.key)) return
|
||||
keyToEntry[sbn.key] = entryFor(sbn)
|
||||
val wasCounted = keyToEntry.containsKey(sbn.key)
|
||||
if (isCounted(sbn)) {
|
||||
// Re-posts are in-place updates: refresh the stored badge
|
||||
// number and flags instead of dropping them, so apps that
|
||||
// update one notification (e.g. K-9 unread count) stay fresh.
|
||||
keyToEntry[sbn.key] = entryFor(sbn)
|
||||
} else if (wasCounted) {
|
||||
// The notification became non-counted (e.g. now ongoing or
|
||||
// group summary); drop it rather than keep a stale entry.
|
||||
keyToEntry.remove(sbn.key)
|
||||
} else {
|
||||
// Never counted and not counted now: fast path, no publish.
|
||||
return
|
||||
}
|
||||
publish()
|
||||
}
|
||||
}
|
||||
@@ -54,11 +64,16 @@ class NotificationBadgeService : NotificationListenerService() {
|
||||
private fun entryFor(sbn: StatusBarNotification) =
|
||||
BadgeEntry(userIdKey(sbn), sbn.notification.number)
|
||||
|
||||
/** Only swipe-away, non-ongoing notifications get a dot. */
|
||||
/** Only swipe-away, non-ongoing, non-summary notifications get a dot. */
|
||||
private fun isCounted(sbn: StatusBarNotification): Boolean {
|
||||
// USER_ALL notifications (userId -1) can never match a launcher icon.
|
||||
if (sbn.userId < 0) return false
|
||||
val notification = sbn.notification
|
||||
if (!sbn.isClearable) return false
|
||||
if (notification.flags and Notification.FLAG_ONGOING_EVENT != 0) return false
|
||||
// Group summaries aggregate their children (AOSP Launcher3 excludes
|
||||
// them too); counting them double-counts a group's messages.
|
||||
if (notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -75,6 +90,7 @@ class NotificationBadgeService : NotificationListenerService() {
|
||||
counts[entry.packageKey] = (counts[entry.packageKey] ?: 0) + increment
|
||||
}
|
||||
notificationCounts.value = counts
|
||||
Log.d("BadgeService", "publish counts=" + counts)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -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.PreferenceHelper
|
||||
import com.github.droidworksstudio.launcher.repository.AppInfoRepository
|
||||
import com.github.droidworksstudio.launcher.service.WidgetHostManager
|
||||
import com.github.droidworksstudio.launcher.utils.Constants
|
||||
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
||||
import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel
|
||||
@@ -77,6 +78,9 @@ class MainActivity : AppCompatActivity() {
|
||||
@Inject
|
||||
lateinit var appDao: AppInfoDAO
|
||||
|
||||
@Inject
|
||||
lateinit var widgetHostManager: WidgetHostManager
|
||||
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
private lateinit var handler: Handler
|
||||
|
||||
@@ -413,6 +417,13 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
// Widget configure activity finished; hand off to the widget host before
|
||||
// the generic error handling below (which would toast on cancel).
|
||||
if (requestCode == WidgetHostManager.REQUEST_ADD_WIDGET) {
|
||||
widgetHostManager.onConfigureResult(resultCode)
|
||||
return
|
||||
}
|
||||
|
||||
if (resultCode != RESULT_OK) {
|
||||
applicationContext.showLongToast("Intent Error")
|
||||
return
|
||||
|
||||
@@ -116,6 +116,7 @@ class HomeFragment : Fragment(),
|
||||
setupRecyclerView()
|
||||
observeSwipeTouchListener()
|
||||
observeUserInterfaceSettings()
|
||||
observeNotificationBadges()
|
||||
|
||||
// Nerd-font weather glyphs (nf-weather-*) live in the bundled
|
||||
// subset font; the system font here is JetBrainsMonoNerdFont but
|
||||
@@ -247,16 +248,21 @@ class HomeFragment : Fragment(),
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
NotificationBadgeService.notificationCounts.collect {
|
||||
val recyclerView = binding.appListAdapter
|
||||
for (i in 0 until recyclerView.childCount) {
|
||||
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
|
||||
if (holder is HomeViewHolder) {
|
||||
val position = holder.bindingAdapterPosition
|
||||
if (position != androidx.recyclerview.widget.RecyclerView.NO_POSITION) {
|
||||
holder.bind(homeAdapter.currentList[position])
|
||||
}
|
||||
}
|
||||
}
|
||||
rebindVisibleHomeRows()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-bind the visible home rows so dots appear/disappear immediately. */
|
||||
private fun rebindVisibleHomeRows() {
|
||||
val recyclerView = binding.appListAdapter
|
||||
for (i in 0 until recyclerView.childCount) {
|
||||
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
|
||||
if (holder is HomeViewHolder) {
|
||||
val position = holder.bindingAdapterPosition
|
||||
if (position != androidx.recyclerview.widget.RecyclerView.NO_POSITION) {
|
||||
holder.bind(homeAdapter.currentList[position])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,6 +349,12 @@ class HomeFragment : Fragment(),
|
||||
if (it) loadCurrentWeather()
|
||||
}
|
||||
|
||||
// Flipping the notification-dots toggle must re-render the home icons
|
||||
// immediately (the HomeFragment stays alive under the settings screen).
|
||||
preferenceViewModel.showNotificationDotsLiveData.observe(viewLifecycleOwner) {
|
||||
rebindVisibleHomeRows()
|
||||
}
|
||||
|
||||
binding.apply {
|
||||
mainView.hideKeyboard()
|
||||
|
||||
@@ -381,7 +393,9 @@ class HomeFragment : Fragment(),
|
||||
is AppHelper.MetWeatherResult.Success -> {
|
||||
binding.currentWeather.text = appHelper.buildCurrentWeatherText(
|
||||
result.temperature,
|
||||
result.symbolCode
|
||||
result.symbolCode,
|
||||
result.temperature4h,
|
||||
result.symbol4h,
|
||||
)
|
||||
binding.currentWeather.visibility = View.VISIBLE
|
||||
}
|
||||
@@ -661,7 +675,6 @@ class HomeFragment : Fragment(),
|
||||
binding.mainView.hideKeyboard()
|
||||
observeUserInterfaceSettings()
|
||||
observeFavoriteAppList()
|
||||
observeNotificationBadges()
|
||||
if (preferenceHelper.showCurrentWeather) loadCurrentWeather()
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ class SettingsFeaturesFragment : Fragment(),
|
||||
lockSettingsSwitchCompat.isChecked = preferenceHelper.settingsLock
|
||||
disableAnimationsSwitchCompat.isChecked = preferenceHelper.disableAnimations
|
||||
showNotificationDotsSwitchCompat.isChecked = preferenceHelper.showNotificationDots
|
||||
showHomeWidgetsSwitchCompat.isChecked = preferenceHelper.showHomeWidgets
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +467,12 @@ class SettingsFeaturesFragment : Fragment(),
|
||||
openNotificationAccessSettings()
|
||||
}
|
||||
}
|
||||
|
||||
showHomeWidgetsSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
|
||||
preferenceViewModel.setShowHomeWidgets(isChecked)
|
||||
val feedbackType = if (isChecked) "on" else "off"
|
||||
appHelper.triggerHapticFeedback(context, feedbackType)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.github.droidworksstudio.launcher.ui.widgets
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.appwidget.AppWidgetHostView
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
@@ -16,8 +18,12 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -35,7 +41,9 @@ import com.github.droidworksstudio.launcher.helper.AppHelper
|
||||
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
||||
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
||||
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
||||
import com.github.droidworksstudio.launcher.service.WidgetHostManager
|
||||
import com.github.droidworksstudio.launcher.utils.Constants
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -62,6 +70,9 @@ class WidgetFragment : Fragment(),
|
||||
@Inject
|
||||
lateinit var appHelper: AppHelper
|
||||
|
||||
@Inject
|
||||
lateinit var widgetHostManager: WidgetHostManager
|
||||
|
||||
private lateinit var navController: NavController
|
||||
|
||||
private lateinit var context: Context
|
||||
@@ -88,6 +99,7 @@ class WidgetFragment : Fragment(),
|
||||
setupBatteryWidget()
|
||||
observeClickListener()
|
||||
observeSwipeTouchListener()
|
||||
setupHostedWidgets()
|
||||
}
|
||||
|
||||
private fun initializeInjectedDependencies() {
|
||||
@@ -119,6 +131,10 @@ class WidgetFragment : Fragment(),
|
||||
linearLayout.addView(relativeLayout)
|
||||
}
|
||||
|
||||
// Hosted app widgets always live after the self-drawn widgets; this
|
||||
// must be re-added here because removeAllViews() above dropped it.
|
||||
linearLayout.addView(binding.widgetHostArea)
|
||||
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@@ -426,6 +442,168 @@ class WidgetFragment : Fragment(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Hosted app widgets (see WidgetHostManager)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (preferenceHelper.showHomeWidgets) {
|
||||
widgetHostManager.startListening()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
if (preferenceHelper.showHomeWidgets) {
|
||||
widgetHostManager.stopListening()
|
||||
}
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
/** Set up the add-widget affordance and recreate any persisted widgets. */
|
||||
private fun setupHostedWidgets() {
|
||||
binding.addWidgetButton.setOnClickListener { showWidgetPicker() }
|
||||
restoreHostedWidgets()
|
||||
}
|
||||
|
||||
/** Re-create views for every widget persisted in prefs and show the area. */
|
||||
private fun restoreHostedWidgets() {
|
||||
binding.widgetHostContainer.removeAllViews()
|
||||
refreshWidgetAreaVisibility()
|
||||
if (!preferenceHelper.showHomeWidgets) return
|
||||
widgetHostManager.restoreWidgetViews().forEach { attachWidgetView(it) }
|
||||
}
|
||||
|
||||
/** Open a picker of installed widget providers. */
|
||||
private fun showWidgetPicker() {
|
||||
val providers = widgetHostManager.installedProviders()
|
||||
if (providers.isEmpty()) {
|
||||
requireContext().showLongToast(getString(R.string.widget_none_available))
|
||||
return
|
||||
}
|
||||
|
||||
val density = requireContext().resources.displayMetrics.densityDpi
|
||||
val dialogContent = LinearLayout(requireContext()).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val paddingDp = (8f * resources.displayMetrics.density)
|
||||
setPadding(0, paddingDp.toInt(), 0, paddingDp.toInt())
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.widget_picker_title)
|
||||
.setNegativeButton(R.string.settings_cancel, null)
|
||||
.create()
|
||||
|
||||
val scroll = android.widget.ScrollView(requireContext())
|
||||
dialog.setView(scroll)
|
||||
|
||||
providers.forEach { provider ->
|
||||
val row = AppCompatTextView(requireContext()).apply {
|
||||
text = provider.loadLabel(context.packageManager)
|
||||
textSize = 16f
|
||||
val hPad = (12f * resources.displayMetrics.density).toInt()
|
||||
val vPad = (14f * resources.displayMetrics.density).toInt()
|
||||
setPadding(hPad, vPad, hPad, vPad)
|
||||
isClickable = true
|
||||
val icon = provider.loadIcon(requireContext(), density)
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
|
||||
compoundDrawablePadding = hPad
|
||||
setOnClickListener {
|
||||
dialog.dismiss()
|
||||
addWidget(provider.provider)
|
||||
}
|
||||
}
|
||||
dialogContent.addView(row)
|
||||
}
|
||||
|
||||
scroll.addView(dialogContent)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
/** Run the manager's add-widget flow and place the resulting view. */
|
||||
private fun addWidget(provider: ComponentName) {
|
||||
widgetHostManager.requestAddWidget(requireActivity(), provider) { result ->
|
||||
when (result) {
|
||||
is WidgetHostManager.AddResult.Bound -> attachWidgetView(result.view)
|
||||
WidgetHostManager.AddResult.BindDenied -> requireContext().showLongToast(
|
||||
getString(R.string.widget_bind_failed)
|
||||
)
|
||||
|
||||
WidgetHostManager.AddResult.Cancelled -> {} // user backed out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a hosted widget view to the container with a small remove affordance. */
|
||||
private fun attachWidgetView(view: AppWidgetHostView) {
|
||||
val density = resources.displayMetrics.density
|
||||
val wrapper = FrameLayout(requireContext())
|
||||
wrapper.layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
|
||||
wrapper.addView(
|
||||
view,
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
)
|
||||
|
||||
// A small "x" in the top-right corner, owned by us (not the remote
|
||||
// widget content) so removal works even for fully interactive widgets
|
||||
// like K-9's, whose own taps open the app instead of reaching a
|
||||
// long-press listener.
|
||||
val removeButton = AppCompatTextView(requireContext()).apply {
|
||||
text = "\u2715"
|
||||
textSize = 16f
|
||||
setTextColor(0x6688AAAA.toInt())
|
||||
val pad = (5f * density).toInt()
|
||||
setPadding(pad, pad, pad, pad)
|
||||
gravity = Gravity.CENTER
|
||||
setOnClickListener { confirmRemoveWidget(view, wrapper) }
|
||||
}
|
||||
wrapper.addView(
|
||||
removeButton,
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
).apply { gravity = Gravity.END or Gravity.TOP }
|
||||
)
|
||||
|
||||
binding.widgetHostContainer.addView(wrapper)
|
||||
|
||||
// Secondary path: long-press still removes where the widget is not
|
||||
// consuming touches.
|
||||
view.setOnLongClickListener {
|
||||
confirmRemoveWidget(view, wrapper)
|
||||
true
|
||||
}
|
||||
refreshWidgetAreaVisibility()
|
||||
}
|
||||
|
||||
private fun confirmRemoveWidget(view: AppWidgetHostView, wrapper: ViewGroup) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.widget_remove_title)
|
||||
.setMessage(R.string.widget_remove_message)
|
||||
.setPositiveButton(R.string.widget_remove) { _, _ ->
|
||||
val id = view.appWidgetId
|
||||
widgetHostManager.removeWidget(view, id)
|
||||
binding.widgetHostContainer.removeView(wrapper)
|
||||
requireContext().showLongToast(getString(R.string.widget_deleted))
|
||||
refreshWidgetAreaVisibility()
|
||||
}
|
||||
.setNegativeButton(R.string.settings_cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/** Show/hide the hosted-widget area based on the feature toggle. */
|
||||
private fun refreshWidgetAreaVisibility() {
|
||||
binding.widgetHostArea.visibility =
|
||||
if (preferenceHelper.showHomeWidgets) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
context.registerReceiver(batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
|
||||
@@ -24,6 +24,8 @@ object Constants {
|
||||
const val MET_WEATHER_TIMESTAMP = "MET_WEATHER_TIMESTAMP"
|
||||
const val MET_WEATHER_TEMPERATURE = "MET_WEATHER_TEMPERATURE"
|
||||
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_UNITS = "WEATHER_UNITS"
|
||||
const val LATITUDE = "LATITUDE"
|
||||
@@ -76,6 +78,8 @@ object Constants {
|
||||
const val DISABLE_ANIMATIONS = "DISABLE_ANIMATIONS"
|
||||
const val SHOW_NOTIFICATION_DOTS = "SHOW_NOTIFICATION_DOTS"
|
||||
const val SHOW_CURRENT_WEATHER = "SHOW_CURRENT_WEATHER"
|
||||
const val SHOW_HOME_WIDGETS = "SHOW_HOME_WIDGETS"
|
||||
const val HOSTED_WIDGETS = "HOSTED_WIDGETS"
|
||||
|
||||
const val HOME_DATE_ALIGNMENT = "HOME_DATE_ALIGNMENT"
|
||||
const val HOME_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT"
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import com.github.droidworksstudio.common.ColorIconsExtensions
|
||||
object NotificationDotHelper {
|
||||
|
||||
/**
|
||||
* Returns a copy of [icon] with a red dot drawn in its top-right corner,
|
||||
* surrounded by a thin white border so it stays visible on busy icons.
|
||||
* Returns a copy of [icon] with a plain red dot drawn in its top-right
|
||||
* corner.
|
||||
*/
|
||||
fun withDot(context: Context, icon: Drawable): Drawable {
|
||||
val source = ColorIconsExtensions.drawableToBitmap(icon)
|
||||
@@ -22,8 +22,9 @@ object NotificationDotHelper {
|
||||
if (width <= 0 || height <= 0) return icon
|
||||
|
||||
val dotRadius = (minOf(width, height) * 0.22f).coerceAtLeast(4f)
|
||||
val centerX = width - dotRadius * 0.75f
|
||||
val centerY = dotRadius * 0.75f
|
||||
// Tangent to the top and right edges so the dot never gets clipped.
|
||||
val centerX = width - dotRadius
|
||||
val centerY = dotRadius
|
||||
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
@@ -34,18 +35,6 @@ object NotificationDotHelper {
|
||||
}
|
||||
canvas.drawCircle(centerX, centerY, dotRadius, dotPaint)
|
||||
|
||||
val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = (dotRadius * 0.28f).coerceAtLeast(1.5f)
|
||||
}
|
||||
canvas.drawCircle(
|
||||
centerX,
|
||||
centerY,
|
||||
dotRadius - borderPaint.strokeWidth / 2f,
|
||||
borderPaint
|
||||
)
|
||||
|
||||
return BitmapDrawable(context.resources, bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ class PreferenceViewModel @Inject constructor(
|
||||
private val disableAnimationsLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
||||
val showNotificationDotsLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
||||
val showCurrentWeatherLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
||||
val showHomeWidgetsLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
||||
private val appGroupPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
|
||||
private val appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
|
||||
|
||||
@@ -309,6 +310,11 @@ class PreferenceViewModel @Inject constructor(
|
||||
showCurrentWeatherLiveData.postValue((preferenceHelper.showCurrentWeather))
|
||||
}
|
||||
|
||||
fun setShowHomeWidgets(showHomeWidgets: Boolean) {
|
||||
preferenceHelper.showHomeWidgets = showHomeWidgets
|
||||
showHomeWidgetsLiveData.postValue((preferenceHelper.showHomeWidgets))
|
||||
}
|
||||
|
||||
fun setAppLanguage(appLanguage: Constants.Language) {
|
||||
preferenceHelper.appLanguage = appLanguage
|
||||
appLanguageLiveData.postValue((preferenceHelper.appLanguage))
|
||||
|
||||
@@ -269,6 +269,37 @@
|
||||
tools:ignore="TouchTargetSizeCheck" />
|
||||
|
||||
</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
|
||||
|
||||
@@ -297,6 +297,36 @@
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
</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.core.widget.NestedScrollView>
|
||||
</FrameLayout>
|
||||
@@ -112,7 +112,16 @@
|
||||
<string name="settings_display_lock_settings">Lock Settings</string>
|
||||
<string name="settings_display_disable_animations">Disable Animations</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="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_color_title">Color</string>
|
||||
|
||||
BIN
dist/EasyLauncher-Internet-v0.3.6-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.6-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.6-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.6-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.7-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.7-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.7-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.7-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.8-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.8-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.8-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.8-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.6-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.6-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.6-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.6-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.7-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.7-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.7-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.7-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.8-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.8-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.8-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.8-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
@@ -14,6 +14,7 @@ dependencyResolutionManagement {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven(url = "https://jitpack.io")
|
||||
maven(url = "https://gitea.haugesenspil.dk/api/packages/jonas/maven")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user