8 Commits

Author SHA1 Message Date
a42f913875 refactor: use shared met-weather library for home-screen MET weather (v0.3.7)
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
Android Release CI / Build, Sign & Release (push) Has been cancelled
- Replace internal MetApiService/MetForecastResponse + Retrofit MET fetch in
  AppHelper with dk.haugesenspil:met-weather (Gitea maven).
- fetchMetWeather now delegates to MetWeatherClient; 15-min cache kept.
- Drop MetApiService.kt and MetForecastResponse.kt.
release build v0.3.7
2026-08-20 12:58:52 +02:00
1d5a568218 feat: default swipe-up gesture opens the widgets screen 2026-08-19 16:19:50 +02:00
daa1748858 feat: host app widgets on the widgets screen (K-9 unread widget) 2026-08-19 16:16:47 +02:00
09cc6a557d docs: notification dots — AOSP semantics detail, K-9 badge behavior, validation trick
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
Nightly Release / release (push) Has been cancelled
Nightly Release / Build, Sign & Release (push) Has been cancelled
Delete Unused Caches / delete (push) Has been cancelled
Close Inactive Issues & Pull Requests / close-issues (push) Has been cancelled
2026-08-19 13:32:55 +02:00
da5542690d release build v0.3.6
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
Android Release CI / Build, Sign & Release (push) Has been cancelled
2026-08-19 13:14:47 +02:00
9799d3057b fix: notification dots render reliably
- NotificationBadgeService: re-posted notifications update the stored
  badge number instead of being dropped; flag changes (clearable ->
  ongoing) remove the entry; exclude group summaries (AOSP Launcher3
  does too) and USER_ALL (-1) records; log each counts publish.
- HomeFragment: register the badge collector once in onViewCreated
  instead of every onResume (collector leak); rebind the visible home
  rows immediately when the notification-dots toggle flips.
- NotificationDotHelper: plain red dot without the white outline;
  position tangent to the top-right corner so the circle is never
  clipped by the icon bitmap.
2026-08-19 13:14:36 +02:00
68ebe26f29 release build v0.3.5
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
2026-08-19 12:16:44 +02:00
84fdb81061 feat: notification dots follow AOSP badge count semantics
NotificationBadgeService now sums Notification.number (or 1 when unset)
per package across counted notifications, matching Launcher3 badge
behavior. A single K-9 notification with number=14 (unread count) now
yields a dot, not just one notification = one dot.
2026-08-19 12:14:52 +02:00
32 changed files with 964 additions and 154 deletions

View File

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

214
WIDGET_HOSTING_TASK.md Normal file
View 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).

View File

@@ -19,8 +19,8 @@ android {
applicationId = "app.easy.launcher" applicationId = "app.easy.launcher"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 35 versionCode = 37
versionName = "0.3.5" versionName = "0.3.7"
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)

View File

@@ -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,11 @@ 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)"
}
@SuppressLint("WrongConstant", "PrivateApi") @SuppressLint("WrongConstant", "PrivateApi")
fun expandNotificationDrawer(context: Context) { fun expandNotificationDrawer(context: Context) {
try { try {
@@ -563,11 +567,12 @@ class AppHelper @Inject constructor() {
} }
/** /**
* 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,37 +589,18 @@ 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 result = MetWeatherResult.Success(
.build() weather.temperatureC.roundToInt(),
weather.symbolCode,
val service = retrofit.create(MetApiService::class.java) )
val response = service.getCompact(latitude.toDouble(), longitude.toDouble()).execute() context.cacheMetWeather(result)
if (response.isSuccessful) { result
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) {
Log.e("AppHelper", "MET fetch: unknown host api.met.no", e)
return MetWeatherResult.Failure("Unknown host: api.met.no")
} 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")
} }
} }

View File

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

View File

@@ -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 = ""
)

View File

@@ -3,6 +3,7 @@ package com.github.droidworksstudio.launcher.service
import android.app.Notification import android.app.Notification
import android.service.notification.NotificationListenerService import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -16,41 +17,63 @@ import kotlinx.coroutines.flow.StateFlow
*/ */
class NotificationBadgeService : NotificationListenerService() { class NotificationBadgeService : NotificationListenerService() {
/** Notification key -> "userId/packageName". Keyed to dedupe updates. */ /** Notification key -> which app it belongs to and its badge number. */
private val keyToPackage = HashMap<String, String>() private val keyToEntry = HashMap<String, BadgeEntry>()
private data class BadgeEntry(val packageKey: String, val number: Int)
override fun onListenerConnected() { override fun onListenerConnected() {
super.onListenerConnected() super.onListenerConnected()
synchronized(keyToPackage) { synchronized(keyToEntry) {
keyToPackage.clear() keyToEntry.clear()
activeNotifications.forEach { sbn -> activeNotifications.forEach { sbn ->
if (isCounted(sbn)) keyToPackage[sbn.key] = userIdKey(sbn) if (isCounted(sbn)) keyToEntry[sbn.key] = entryFor(sbn)
}
publish()
}
Log.d("BadgeService", "connected, active=" + activeNotifications.size +
" counts=" + notificationCounts.value)
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
synchronized(keyToEntry) {
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() publish()
} }
} }
override fun onNotificationPosted(sbn: StatusBarNotification) {
if (!isCounted(sbn)) return
synchronized(keyToPackage) {
// A posted notification with an existing key is just an update.
if (keyToPackage.containsKey(sbn.key)) return
keyToPackage[sbn.key] = userIdKey(sbn)
publish()
}
}
override fun onNotificationRemoved(sbn: StatusBarNotification) { override fun onNotificationRemoved(sbn: StatusBarNotification) {
synchronized(keyToPackage) { synchronized(keyToEntry) {
if (keyToPackage.remove(sbn.key) != null) publish() if (keyToEntry.remove(sbn.key) != null) publish()
} }
} }
/** Only swipe-away, non-ongoing notifications get a dot. */ private fun entryFor(sbn: StatusBarNotification) =
BadgeEntry(userIdKey(sbn), sbn.notification.number)
/** Only swipe-away, non-ongoing, non-summary notifications get a dot. */
private fun isCounted(sbn: StatusBarNotification): Boolean { 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 val notification = sbn.notification
if (!sbn.isClearable) return false if (!sbn.isClearable) return false
if (notification.flags and Notification.FLAG_ONGOING_EVENT != 0) 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 return true
} }
@@ -58,11 +81,16 @@ class NotificationBadgeService : NotificationListenerService() {
"${sbn.userId}/" + sbn.packageName "${sbn.userId}/" + sbn.packageName
private fun publish() { private fun publish() {
// AOSP launcher badge semantics: for each counted notification add
// its badge number (Notification.number), or 1 when unset, so e.g.
// K-9's unread-count badge shows a dot.
val counts = HashMap<String, Int>() val counts = HashMap<String, Int>()
keyToPackage.values.forEach { key -> keyToEntry.values.forEach { entry ->
counts[key] = (counts[key] ?: 0) + 1 val increment = entry.number.coerceAtLeast(1)
counts[entry.packageKey] = (counts[entry.packageKey] ?: 0) + increment
} }
notificationCounts.value = counts notificationCounts.value = counts
Log.d("BadgeService", "publish counts=" + counts)
} }
companion object { companion object {

View File

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

View File

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

View File

@@ -116,6 +116,7 @@ class HomeFragment : Fragment(),
setupRecyclerView() setupRecyclerView()
observeSwipeTouchListener() observeSwipeTouchListener()
observeUserInterfaceSettings() observeUserInterfaceSettings()
observeNotificationBadges()
// Nerd-font weather glyphs (nf-weather-*) live in the bundled // Nerd-font weather glyphs (nf-weather-*) live in the bundled
// subset font; the system font here is JetBrainsMonoNerdFont but // subset font; the system font here is JetBrainsMonoNerdFont but
@@ -247,16 +248,21 @@ class HomeFragment : Fragment(),
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) { repeatOnLifecycle(Lifecycle.State.CREATED) {
NotificationBadgeService.notificationCounts.collect { NotificationBadgeService.notificationCounts.collect {
val recyclerView = binding.appListAdapter rebindVisibleHomeRows()
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]) /** 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() 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 { binding.apply {
mainView.hideKeyboard() mainView.hideKeyboard()
@@ -661,7 +673,6 @@ class HomeFragment : Fragment(),
binding.mainView.hideKeyboard() binding.mainView.hideKeyboard()
observeUserInterfaceSettings() observeUserInterfaceSettings()
observeFavoriteAppList() observeFavoriteAppList()
observeNotificationBadges()
if (preferenceHelper.showCurrentWeather) loadCurrentWeather() if (preferenceHelper.showCurrentWeather) loadCurrentWeather()
} }

View File

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

View File

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

View File

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

View File

@@ -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>
}

View File

@@ -12,8 +12,8 @@ import com.github.droidworksstudio.common.ColorIconsExtensions
object NotificationDotHelper { object NotificationDotHelper {
/** /**
* Returns a copy of [icon] with a red dot drawn in its top-right corner, * Returns a copy of [icon] with a plain red dot drawn in its top-right
* surrounded by a thin white border so it stays visible on busy icons. * corner.
*/ */
fun withDot(context: Context, icon: Drawable): Drawable { fun withDot(context: Context, icon: Drawable): Drawable {
val source = ColorIconsExtensions.drawableToBitmap(icon) val source = ColorIconsExtensions.drawableToBitmap(icon)
@@ -22,8 +22,9 @@ object NotificationDotHelper {
if (width <= 0 || height <= 0) return icon if (width <= 0 || height <= 0) return icon
val dotRadius = (minOf(width, height) * 0.22f).coerceAtLeast(4f) val dotRadius = (minOf(width, height) * 0.22f).coerceAtLeast(4f)
val centerX = width - dotRadius * 0.75f // Tangent to the top and right edges so the dot never gets clipped.
val centerY = dotRadius * 0.75f val centerX = width - dotRadius
val centerY = dotRadius
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
@@ -34,18 +35,6 @@ object NotificationDotHelper {
} }
canvas.drawCircle(centerX, centerY, dotRadius, dotPaint) 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) return BitmapDrawable(context.resources, bitmap)
} }
} }

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
dist/EasyLauncher-v0.3.6-Signed.apk vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
dist/EasyLauncher-v0.3.7-Signed.apk vendored Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -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")
} }
} }