20 Commits
v0.3.3 ... main

Author SHA1 Message Date
44d7e81801 fix(weather): keep met-weather DTOs from R8 so Gson works; add +4h reading (v0.3.8)
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
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
GitHub Actions Version Updater / build (push) Has been cancelled
- ProGuard keep rule for dk.haugesenspil.metweather.** (R8 was obfuscating the
  Gson DTOs -> ClassCastException, weather glyph vanished).
- Home current-weather now shows 'current  ->  ~4h' (nearest MET hourly point),
  cached in met_weather_prefs.
release build v0.3.8
2026-08-20 13:19:21 +02:00
553d1802c7 docs: CLAUDE.md — MET weather via shared met-weather library; bump example to 0.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
2026-08-20 13:00:10 +02:00
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
c972125fef 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
Android Release CI / Build, Sign & Release (push) Has been cancelled
2026-08-19 11:51:36 +02:00
cd94721fc8 docs: add CLAUDE.md with build, signing, and on-device testing notes 2026-08-19 11:51:21 +02:00
8cb9166f41 fix: widen weather glyph/temperature gap; drop debug logs 2026-08-19 11:51:21 +02:00
010ff8a0c8 feat: current weather on home screen (MET/Yr API, no key)
The Yr app (no.nrk.yr) exposes no data to third parties; its backend,
api.met.no locationforecast 2.0, works with no API key (User-Agent header
only). New toggle 'Show Current Weather' under Display, right after 'Show
App Icons'. The home element is formatted exactly like the daily word and
shows a nerd-font weather glyph (nf-weather-*, from the installed
JetBrainsMonoNerdFont) plus the temperature in Celsius, e.g. '\uE312 16°'.

A 95KB subset of JetBrainsMonoNerdFont (ASCII + weather glyphs, OFL) is
bundled as R.font.jetbrains_mono_nf_weather so the glyphs render even if
the launcher font setting changes.

Uses the launcher's existing saved location (lat/lon prefs).
2026-08-19 11:15:21 +02:00
5288c980d0 feat: notification dots on app icons
New NotificationBadgeService (NotificationListenerService) tracks pending,
dismissible notifications per app. Home, favorite, and drawer icons get a
red dot in the top-right corner when the app has active notifications.

Settings: 'Notification Dots' toggle under Display features; enabling it
opens the system notification-access screen when permission is missing.
Dots update live via a StateFlow observed by the fragments.
2026-08-19 10:53:39 +02:00
0aaf00988d feat: scroll app list from anywhere on screen; gate search item animation
DrawFragment: app-list touch area now forwards vertical drags/flings to the
drawer RecyclerView, so the list scrolls even when grabbing empty space
right of the app names. OnSwipeTouchListener gained no-op vertical scroll
and fling hooks.

When animations are disabled, drop the RecyclerView item animator so
DiffUtil search updates no longer pan entries up into place.
2026-08-19 10:39:40 +02:00
e69796535f fix: next alarm time follows system 24-hour format setting
getNextAlarm hardcoded 'hh:mm a' (12-hour). Now uses
DateFormat.is24HourFormat to pick HH:mm vs hh:mm a.
2026-08-19 10:18:57 +02:00
16a75a9e7e release build v0.3.4
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
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-18 10:22:03 +02:00
bea62e8d88 better fuzzy search
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-18 10:13:25 +02:00
e81b4e8ac1 ignore .pi 2026-08-18 10:13:11 +02:00
62 changed files with 1902 additions and 177 deletions

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ git-cliff*
/app/build
/app/debug
diff.*
/.pi

219
CLAUDE.md Normal file
View File

@@ -0,0 +1,219 @@
# Working with this EasyLauncher fork
Quick reference for agents working on this fork (Gitea: `jonas/EasyLauncher`,
installed on the Unihertz Titan 2 Elite as `app.easy.launcher`).
**CLAUDE.md in ~/titan2-elite is the source of truth for the phone itself.**
## Golden rules
1. The installed app id is **`app.easy.launcher`** — NOT upstream's
`com.github.droidworksstudio.launcher`. Every fork build must keep this id.
2. Release APKs are signed with `~/android-keystores/easylauncher-release.jks`
(alias `easylauncher`, pass in `easylauncher-release.pass`). Never commit an
unsigned or differently-signed APK to `dist/`.
3. `adb install -r dist/...-Signed.apk` updates the phone in place (same
signature). A debug build cannot be installed over it (signature mismatch).
4. After a phone reboot the phone is CE-locked until the user enters the PIN;
app prefs/location are not visible to the app until then. Ask the user to
unlock instead of debugging it.
5. Never fetch weather without a real location (lat/lon 0,0 must be treated as
"no location").
## Build
```bash
export JAVA_HOME=~/jdk21 # Gradle 8.13, compileSdk/targetSdk 36, minSdk 24
./gradlew :app:compileWithInternetReleaseKotlin :app:compileWithoutInternetReleaseKotlin --offline
./gradlew :app:assembleWithInternetRelease :app:assembleWithoutInternetRelease --offline
```
- Two flavors: `withInternet` (INTERNET + location permissions, "Easy Launcher")
and `withoutInternet`.
- Outputs: `app/build/outputs/apk/{withInternet,withoutInternet}/release/`.
- `weather.properties` (OpenWeatherMap key) is optional and absent here — the
OWM weather widget is dead without it. The home-screen current-weather
element uses the MET/Yr API instead (no key needed, see below).
## Release flow (see git history for the v0.3.x pattern)
1. Bump `versionCode`/`versionName` in `app/build.gradle.kts`
(currently 38 / 0.3.8).
2. Build both flavors, then sign each:
```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 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.
4. Commit as `release build vX.Y.Z` (includes the dist APKs + .idsig files),
annotated tag `vX.Y.Z`, push `main` + tag to `origin` (Gitea), create a
Gitea release with both APKs as assets.
## On-device testing (Titan 2 Elite via adb)
- Wake: `input keyevent KEYCODE_WAKEUP`; go home: `input keyevent KEYCODE_HOME`.
- Open the app drawer: `input keyevent KEYCODE_SPACE` (the physical-keyboard
"key press -> app list" trigger; requires MainActivity focused).
- Scroll the drawer from the right edge: `input swipe 1000 850 1000 250 250`.
- Screenshot: `adb exec-out screencap -p > s.png`; view text via
`uiautomator dump`.
- Inspect the launcher's own state with `su -c '...'` (root via Magisk).
### Editing app prefs from the host (fragile — read carefully)
Prefs live in `/data/data/app.easy.launcher/shared_prefs/`. To change them:
1. Pull the file, edit locally, push back via stdin:
`adb shell "su -c 'cat > <path>'" < localfile`
2. **The file name must end in `.xml`** (`EasyLauncher.pref.xml`,
`EasyWeather.pref.xml`) — `getSharedPreferences("name")` appends `.xml`.
A missing suffix silently reads as an EMPTY map.
3. **Run `restorecon -F <file>`** — files created via `su cat` get the wrong
SELinux context (`s0` instead of `s0:c18,c257,c512,c768`) and the app can't
read them (silent empty map).
4. `am force-stop app.easy.launcher` then press HOME so the process restarts
and re-reads the file.
### Notification dots
- 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`
- 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)
- 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
JetBrainsMonoNerdFont (ASCII + 38 weather glyphs + °). The phone's system
font IS `JetBrainsMonoNerdFont` (fonts.xml default), but the bundled subset
guarantees rendering even if the launcher-font setting changes. Regenerate
the subset with fontTools if more glyphs are ever needed (source:
`/system/fonts/JetBrainsMonoNerdFont-Regular.ttf`).
- Display mirrors the daily word (uses the daily-word color/size/alignment
prefs); text = `"$glyph $temp°"` (two spaces).
- Needs a real saved location: `EasyWeather.pref.xml` keys `LATITUDE`/
`LONGITUDE` (floats). MainActivity saves the real fix there; for testing you
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
use double tap to lock") pops on home when `ActionService` isn't running.
Enable the service in system settings to suppress it during testing.
- `Application.setCustomFont` patches the `Typeface` DEFAULT/MONO/SERIF/SANS
static fields when a non-System launcher font is selected.
- With "Disable Animations" on, the drawer RecyclerView item animator is null
and navigation transitions are skipped.
- Drawer whole-screen scrolling: `appListTouchArea` forwards vertical drags/
flings through `OnSwipeTouchListener` hooks (`onVerticalScroll`/`onVerticalFling`).
Signs were verified against AOSP source: `scrollBy(0, distanceY)` is
pass-through; fling takes the NEGATED pointer velocity.

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"
minSdk = 24
targetSdk = 36
versionCode = 33
versionName = "0.3.3"
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)

View File

@@ -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.** { *; }

View File

@@ -103,6 +103,15 @@
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
</service>
<service
android:name=".service.NotificationBadgeService"
android:exported="true"
android:label="@string/notification_badge_service_label"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@@ -0,0 +1 @@
This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: https://scripts.sil.org/OFL

View File

@@ -2,65 +2,133 @@ package com.github.droidworksstudio.fuzzywuzzy
import com.github.droidworksstudio.launcher.data.entities.AppInfo
import java.text.Normalizer
import java.util.*
/**
* Fast in-memory fuzzy search index for app names.
*
* Matching runs on a normalized form of the app name: uppercase, without
* diacritical marks and without separators, so "fdroid" finds "F-Droid".
* A query matches when all of its characters appear in the name in order
* (a subsequence match), but not necessarily consecutively: "fro" finds
* "F-Droid" too.
*
* The normalized names and word boundaries are precomputed once per app list
* ([buildIndex]), so scoring one keystroke is a plain scan over plain strings
* — no regex, no allocations per app, no database query. A few hundred apps
* score in well under a millisecond.
*/
object FuzzyFinder {
fun scoreApp(app: AppInfo, searchChars: String, topScore: Int): Int {
val appChars = app.appName
val fuzzyScore = calculateFuzzyScore(
normalizeString(appChars),
normalizeString(searchChars)
)
/** One app with its precomputed search data. */
class IndexedApp(val app: AppInfo) {
/** App name, uppercased, without diacritics and separators. */
val normalizedName: String = normalize(app.appName)
return (fuzzyScore * topScore).toInt()
/** True at positions in [normalizedName] that begin a word. */
val wordStarts: BooleanArray = wordStartsOf(app.appName, normalizedName)
}
fun normalizeString(appLabel: String, searchChars: String): Boolean {
return (appLabel.contains(searchChars, true) or
Normalizer.normalize(appLabel, Normalizer.Form.NFD)
.replace(Regex("\\p{InCombiningDiacriticalMarks}+"), "")
.replace(Regex("[-_+,. ]"), "")
.contains(searchChars, true))
/** Precomputes search data for a list of apps. Call once per app list change. */
fun buildIndex(apps: List<AppInfo>): List<IndexedApp> = apps.map(::IndexedApp)
/** Normalizes a name or a query: uppercase, no diacritics, no separators. */
fun normalize(input: String): String {
val nfd = Normalizer.normalize(input, Normalizer.Form.NFD)
val sb = StringBuilder(nfd.length)
for (ch in nfd) {
if (ch.isLetterOrDigit()) {
sb.append(ch.uppercaseChar())
}
}
return sb.toString()
}
private fun normalizeString(input: String): String {
// Remove diacritical marks and special characters, and convert to uppercase
return input
.uppercase(Locale.getDefault())
.replace(Regex("[\\p{InCombiningDiacriticalMarks}-_+,.]"), "")
}
private fun wordStartsOf(original: String, normalized: String): BooleanArray {
val nfd = Normalizer.normalize(original, Normalizer.Form.NFD)
val starts = BooleanArray(normalized.length)
var atWordStart = true
var prevWasLower = false
var normalizedIndex = 0
for (ch in nfd) {
when {
ch.isLetterOrDigit() -> {
// Camel case: "easyLauncher" starts a word at the uppercase letter.
starts[normalizedIndex] = atWordStart || (prevWasLower && ch.isUpperCase())
atWordStart = false
prevWasLower = ch.isLowerCase()
normalizedIndex++
}
private fun calculateFuzzyScore(s1: String, s2: String): Float {
val m = s1.length
val n = s2.length
var matchCount = 0
var s1Index = 0
Character.getType(ch) == Character.NON_SPACING_MARK.toInt() -> {
// Combining mark left over from NFD (the accent of "é") — ignore.
}
// Iterate over each character in s2 and check if it exists in s1
for (c2 in s2) {
var found = false
// Start searching for c2 from the current s1Index
for (j in s1Index until m) {
if (s1[j] == c2) {
found = true
// Update s1Index to the next position for the next iteration
s1Index = j + 1
break
else -> {
// Separator: the next letter begins a new word.
atWordStart = true
prevWasLower = false
}
}
}
return starts
}
// If the current character in s2 is not found in s1, return a score of 0
if (!found) {
return 0f
}
/**
* Scores [entry] against an already normalized [query] (see [normalize]).
*
* Returns 0 when the query is not a subsequence of the app name, otherwise
* a score from 1 to 100. Matches at the start of the name, consecutive
* runs and word boundaries score higher, so the best match ranks on top.
* An exact match always scores 100.
*/
fun score(entry: IndexedApp, query: String): Int {
val name = entry.normalizedName
val qLen = query.length
val nLen = name.length
if (qLen == 0) return 100
if (qLen > nLen) return 0
// Increment the match count
matchCount++
var nameIndex = 0
var prevMatch = -2
var run = 0
var runPoints = 0
var boundaryHits = 0
var firstMatch = -1
for (i in 0 until qLen) {
val c = query[i]
while (nameIndex < nLen && name[nameIndex] != c) nameIndex++
if (nameIndex == nLen) return 0
if (firstMatch < 0) firstMatch = nameIndex
run = if (nameIndex == prevMatch + 1) run + 1 else 1
runPoints += run
if (entry.wordStarts[nameIndex]) boundaryHits++
prevMatch = nameIndex
nameIndex++
}
// Calculate the score as the ratio of matched characters to the longer string length
return matchCount.toFloat() / maxOf(m, n)
if (firstMatch == 0 && qLen == nLen) return 100 // exact match
// Where does the match start? 40 points at the start, falling off quickly.
val startScore = when {
firstMatch == 0 -> 40
firstMatch == 1 -> 30
else -> (20 - firstMatch).coerceAtLeast(0)
}
// Are the query characters consecutive? Up to 30 points. runPoints is
// the sum of run lengths: 1 + 2 + ... + qLen for a perfectly
// consecutive match, qLen for a fully scattered one.
val consecutiveScore = if (qLen > 1) {
val scattered = runPoints.toDouble() / qLen
((scattered - 1.0) / ((qLen + 1.0) / 2.0 - 1.0)).coerceIn(0.0, 1.0)
} else {
0.0
}
// Do matches hit word starts? Up to 30 points.
val boundaryScore = boundaryHits.toDouble() / qLen
return (startScore + 30 * consecutiveScore + 30 * boundaryScore).toInt()
}
}

View File

@@ -1,6 +1,5 @@
package com.github.droidworksstudio.launcher.adapter.drawer
import android.annotation.SuppressLint
import android.view.LayoutInflater
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
@@ -49,10 +48,4 @@ class DrawAdapter(
override fun areContentsTheSame(oldItem: AppInfo, newItem: AppInfo) =
oldItem == newItem
}
@SuppressLint("NotifyDataSetChanged")
fun updateDataWithStateFlow(newData: List<AppInfo>) {
submitList(newData.toMutableList())
notifyDataSetChanged()
}
}

View File

@@ -49,9 +49,6 @@ interface AppInfoDAO {
@Query("SELECT * FROM app WHERE is_lock = 1 ORDER BY app_order ASC")
fun getLockAppsFlow(): Flow<List<AppInfo>>
@Query("SELECT * FROM app WHERE is_hidden = 0 ORDER BY app_name COLLATE NOCASE ASC")
fun searchApps(): Flow<List<AppInfo>>
@Update
suspend fun updateAppInfo(appInfo: AppInfo)

View File

@@ -35,12 +35,15 @@ import com.github.droidworksstudio.launcher.data.dao.AppInfoDAO
import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.helper.weather.WeatherResponse
import com.github.droidworksstudio.launcher.utils.Constants
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
import retrofit2.converter.gson.GsonConverterFactory
import java.net.UnknownHostException
@@ -53,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 {
@@ -264,7 +273,9 @@ class AppHelper @Inject constructor() {
if (nextAlarmClock == null) return "No alarm is set."
val alarmTime = nextAlarmClock.triggerTime
val formattedTime = SimpleDateFormat("EEE, MMM d hh:mm a", Locale.getDefault()).format(alarmTime)
val is24Hour = android.text.format.DateFormat.is24HourFormat(context)
val timePattern = if (is24Hour) "EEE, MMM d HH:mm" else "EEE, MMM d hh:mm a"
val formattedTime = SimpleDateFormat(timePattern, Locale.getDefault()).format(alarmTime)
val drawable = AppCompatResources.getDrawable(context, R.drawable.ic_alarm_clock)
val fontSize = TypedValue.applyDimension(
@@ -550,4 +561,105 @@ class AppHelper @Inject constructor() {
// Data class to hold cached weather data along with timestamp
data class CachedWeatherData(val timestamp: Long, val weatherResponse: WeatherResponse)
sealed class 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 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.
*/
suspend fun fetchMetWeather(
context: Context,
latitude: Float,
longitude: Float,
): MetWeatherResult {
if (latitude == 0f && longitude == 0f) {
return MetWeatherResult.Failure("No location available")
}
val cached = context.getMetWeatherFromCache()
if (cached?.let {
System.currentTimeMillis() - it.first < TimeUnit.MINUTES.toMillis(15)
} == true
) {
return cached.second
}
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))
}
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)
MetWeatherResult.Failure(e.message ?: "MET fetch failed")
}
}
/**
* 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,
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) {
val sharedPreferences = getSharedPreferences(Constants.MET_WEATHER_PREFS, Context.MODE_PRIVATE)
sharedPreferences.edit()
.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()
}
private fun Context.getMetWeatherFromCache(): Pair<Long, MetWeatherResult>? {
val sharedPreferences = getSharedPreferences(Constants.MET_WEATHER_PREFS, Context.MODE_PRIVATE)
val timestamp = sharedPreferences.getLong(Constants.MET_WEATHER_TIMESTAMP, -1L)
if (timestamp == -1L) return null
val temperature = sharedPreferences.getInt(Constants.MET_WEATHER_TEMPERATURE, 0)
val symbol = sharedPreferences.getString(Constants.MET_WEATHER_SYMBOL, "cloudy") ?: "cloudy"
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
}
}

View File

@@ -198,6 +198,18 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
get() = prefs.getBoolean(Constants.DISABLE_ANIMATIONS, false)
set(value) = prefs.edit().putBoolean(Constants.DISABLE_ANIMATIONS, value).apply()
var showNotificationDots: Boolean
get() = prefs.getBoolean(Constants.SHOW_NOTIFICATION_DOTS, false)
set(value) = prefs.edit().putBoolean(Constants.SHOW_NOTIFICATION_DOTS, value).apply()
var showCurrentWeather: Boolean
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 {
@@ -244,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

View File

@@ -90,6 +90,7 @@ internal open class OnSwipeTouchListener(context: Context?, private val preferen
}
// Vertical swipe
else {
onVerticalScroll(distanceY)
if (abs(diffY) > swipeThreshold && abs(distanceY) > swipeScrollThreshold) {
if (diffY > 0) onSwipeDown() else onSwipeUp()
}
@@ -115,6 +116,7 @@ internal open class OnSwipeTouchListener(context: Context?, private val preferen
}
} else {
if (abs(diffY) > swipeThreshold && abs(velocityY) > swipeVelocityThreshold) {
onVerticalFling(velocityY)
if (diffY < 0) onSwipeUp() else onSwipeDown()
}
}
@@ -129,6 +131,21 @@ internal open class OnSwipeTouchListener(context: Context?, private val preferen
open fun onSwipeLeft() {}
open fun onSwipeUp() {}
open fun onSwipeDown() {}
/**
* Called for every vertical drag movement (per-event delta, same sign
* convention as RecyclerView's own touch handling: positive when the
* finger moves up). Default is a no-op.
*/
open fun onVerticalScroll(distanceY: Float) {}
/**
* Called on a vertical fling with the raw pointer velocity in pixels per
* second (positive when the finger moved down, same convention as
* VelocityTracker.getYVelocity()). Default is a no-op.
*/
open fun onVerticalFling(velocityY: Float) {}
open fun onLongClick() {}
open fun onDoubleClick() {}
open fun onTripleClick() {}

View File

@@ -53,10 +53,6 @@ class AppInfoRepository @Inject constructor(
}
}
fun searchNote(): Flow<List<AppInfo>> {
return appDao.searchApps()
}
suspend fun updateFavoriteAppInfo(appInfo: AppInfo) = withContext(Dispatchers.IO) {
if (appInfo.favorite) {

View File

@@ -0,0 +1,104 @@
package com.github.droidworksstudio.launcher.service
import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Tracks pending (dismissible) notifications per app so the launcher can show
* a red dot on app icons.
*
* The user must grant notification access in the system settings
* (Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) before this service
* receives any events. Android rebinds it automatically after every boot.
*/
class NotificationBadgeService : NotificationListenerService() {
/** Notification key -> which app it belongs to and its badge number. */
private val keyToEntry = HashMap<String, BadgeEntry>()
private data class BadgeEntry(val packageKey: String, val number: Int)
override fun onListenerConnected() {
super.onListenerConnected()
synchronized(keyToEntry) {
keyToEntry.clear()
activeNotifications.forEach { 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()
}
}
override fun onNotificationRemoved(sbn: StatusBarNotification) {
synchronized(keyToEntry) {
if (keyToEntry.remove(sbn.key) != null) publish()
}
}
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 {
// 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
}
private fun userIdKey(sbn: StatusBarNotification) =
"${sbn.userId}/" + sbn.packageName
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>()
keyToEntry.values.forEach { entry ->
val increment = entry.number.coerceAtLeast(1)
counts[entry.packageKey] = (counts[entry.packageKey] ?: 0) + increment
}
notificationCounts.value = counts
Log.d("BadgeService", "publish counts=" + counts)
}
companion object {
/** Active notification count per app, keyed by "userId/packageName". */
val notificationCounts: MutableStateFlow<Map<String, Int>> =
MutableStateFlow(emptyMap())
fun hasNotifications(userId: Int, packageName: String): Boolean =
notificationCounts.value["$userId/$packageName"]?.let { it > 0 } ?: false
}
}

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.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

View File

@@ -40,6 +40,8 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import kotlin.math.roundToInt
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
@@ -81,6 +83,12 @@ class DrawFragment : Fragment(),
)
}
/**
* In-memory search index, rebuilt whenever the app list changes. Searching
* reads only from this index — no database query per keystroke.
*/
private var searchIndex: List<FuzzyFinder.IndexedApp> = emptyList()
private lateinit var context: Context
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
@@ -104,6 +112,7 @@ class DrawFragment : Fragment(),
observeClickListener()
observeSwipeTouchListener()
observeScrollTouchListener()
observeNotificationBadges()
// Initialize observation of drawer apps
observeDrawerApps()
@@ -151,6 +160,30 @@ class DrawFragment : Fragment(),
setHasFixedSize(false)
layoutManager = StaggeredGridLayoutManager(1, StaggeredGridLayoutManager.VERTICAL)
isNestedScrollingEnabled = false
// Search results update the list with DiffUtil. When animations
// are disabled, drop the item animator so entries don't "pan-up"
// into place while filtering.
if (preferenceHelper.disableAnimations) itemAnimator = null
}
}
private fun observeNotificationBadges() {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
NotificationBadgeService.notificationCounts.collect {
val recyclerView = binding.drawAdapter
for (i in 0 until recyclerView.childCount) {
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
if (holder is DrawViewHolder) {
val position = holder.bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
holder.bind(drawAdapter.currentList[position])
}
}
}
}
}
}
}
@@ -165,10 +198,15 @@ class DrawFragment : Fragment(),
repeatOnLifecycle(Lifecycle.State.CREATED) {
// Collect the drawer apps from the ViewModel
viewModel.drawApps.collect { apps ->
// Update the adapter with the new list of apps
drawAdapter.submitList(apps)
// Update the adapter's data with the new state flow
drawAdapter.updateDataWithStateFlow(apps)
// Rebuild the search index from the fresh app list
searchIndex = FuzzyFinder.buildIndex(apps)
// Keep whatever search results are currently shown, or show the full list
val query = binding.searchViewText.query?.toString().orEmpty()
if (query.isBlank()) {
drawAdapter.submitList(apps)
} else {
drawAdapter.submitList(performSearch(query))
}
}
}
}
@@ -185,8 +223,15 @@ class DrawFragment : Fragment(),
val searchQuery = trimmedQuery.substringAfter("!")
requireContext().searchCustomSearchEngine(preferenceHelper, searchQuery)
} else {
searchApp(trimmedQuery, false)
return true // Exit the function
val results = performSearch(trimmedQuery)
if (results.isEmpty()) {
if (!requireContext().searchOnPlayStore(trimmedQuery)) {
requireContext().openSearch(trimmedQuery)
}
} else {
observeBioAuthCheck(results.first())
drawAdapter.submitList(results)
}
}
}
}
@@ -194,7 +239,13 @@ class DrawFragment : Fragment(),
}
override fun onQueryTextChange(newText: String?): Boolean {
searchApp(newText.toString(), true)
val query = newText.orEmpty()
val results = performSearch(query)
// Auto-open when exactly one app matches the query
if (query.isNotBlank() && results.size == 1 && preferenceHelper.automaticOpenApp) {
observeBioAuthCheck(results.first())
}
drawAdapter.submitList(results)
return true
}
})
@@ -213,7 +264,7 @@ class DrawFragment : Fragment(),
binding.apply {
mainView.setOnTouchListener(getSwipeGestureListener(context))
touchArea.setOnTouchListener(getSwipeGestureListener(context))
appListTouchArea.setOnTouchListener(getSwipeGestureListener(context))
appListTouchArea.setOnTouchListener(getAppListSwipeGestureListener(context))
}
}
@@ -278,123 +329,102 @@ class DrawFragment : Fragment(),
return object : OnSwipeTouchListener(context, preferenceHelper) {
override fun onSwipeLeft() {
super.onSwipeLeft()
val actionTypeNavOptions: NavOptions? =
if (preferenceHelper.disableAnimations) null
else appHelper.getActionType(Constants.Swipe.Left)
Handler(Looper.getMainLooper()).post {
findNavController().navigate(
R.id.HomeFragment,
null,
actionTypeNavOptions
)
}
navigateToHome(Constants.Swipe.Left)
}
override fun onSwipeRight() {
super.onSwipeRight()
val actionTypeNavOptions: NavOptions? =
if (preferenceHelper.disableAnimations) null
else appHelper.getActionType(Constants.Swipe.Right)
Handler(Looper.getMainLooper()).post {
findNavController().navigate(
R.id.HomeFragment,
null,
actionTypeNavOptions
)
}
navigateToHome(Constants.Swipe.Right)
}
}
}
private fun searchApp(query: String, isSearching: Boolean) {
// Launch a coroutine tied to the lifecycle of the view
viewLifecycleOwner.lifecycleScope.launch {
// Repeat the block when the lifecycle is at least CREATED
repeatOnLifecycle(Lifecycle.State.CREATED) {
val trimmedQuery = query.trim()
/**
* Swipe listener for the app list area.
*
* Behaves like [getSwipeGestureListener] (left/right swipes navigate
* home), but vertical drags and flings over the empty space around the
* list (e.g. right of the app names) scroll the list itself. This makes
* the drawer scrollable from the whole screen, not just the app rows.
*/
private fun getAppListSwipeGestureListener(context: Context): View.OnTouchListener {
return object : OnSwipeTouchListener(context, preferenceHelper) {
override fun onSwipeLeft() {
super.onSwipeLeft()
navigateToHome(Constants.Swipe.Left)
}
// Collect search results from the ViewModel
viewModel.searchAppInfo().collect { searchResults ->
// Filter and score results using FuzzyFinder
val filteredResults = searchResults
.map { appInfo ->
val score = FuzzyFinder.scoreApp(appInfo, trimmedQuery, Constants.FILTER_STRENGTH_MAX)
appInfo to score // Pairing app info with its score
}
.filter { it.second > 25 } // Only keep results with a positive score
.sortedByDescending { it.second } // Sort results by score, descending
override fun onSwipeRight() {
super.onSwipeRight()
navigateToHome(Constants.Swipe.Right)
}
// Applying additional filtering based on preferences
val scoredApps = filteredResults.toMap()
override fun onVerticalScroll(distanceY: Float) {
super.onVerticalScroll(distanceY)
binding.drawAdapter.scrollBy(0, distanceY.roundToInt())
}
val finalResults = if (preferenceHelper.filterStrength >= 1) {
// Filtering based on score strength
if (preferenceHelper.searchFromStart) {
// Filter apps that start with the search query and score higher than the filter strength
scoredApps.filter { (app, _) ->
app.appName.startsWith(trimmedQuery, ignoreCase = true)
}
.filter { (_, score) -> score > preferenceHelper.filterStrength }
.map { it.key }
.toMutableList()
} else {
// Filter based on score strength alone
scoredApps.filterValues { it > preferenceHelper.filterStrength }
.keys
.toMutableList()
}
} else {
if (preferenceHelper.searchFromStart) {
// Filter apps that start with the search query and score higher than the filter strength
searchResults.filter { app ->
FuzzyFinder.normalizeString(app.appName, trimmedQuery) ||
app.appName.startsWith(trimmedQuery, ignoreCase = true)
}.toMutableList()
} else {
// If filter strength is less than 1, normalize app names for both cases
searchResults.filter { app ->
FuzzyFinder.normalizeString(app.appName, trimmedQuery)
}.toMutableList()
}
}
val numberOfItemsLeft = finalResults.size
val appResults = finalResults.firstOrNull()
if (isSearching) {
when (numberOfItemsLeft) {
1 -> {
appResults?.let { appInfo ->
if (preferenceHelper.automaticOpenApp) observeBioAuthCheck(appInfo)
}
drawAdapter.submitList(finalResults)
}
else -> {
drawAdapter.submitList(finalResults)
}
}
if (trimmedQuery.isEmpty()) {
drawAdapter.submitList(searchResults)
}
} else {
if (numberOfItemsLeft == 0 && !requireContext().searchOnPlayStore(trimmedQuery)) {
requireContext().openSearch(trimmedQuery)
} else {
appResults?.let { appInfo ->
observeBioAuthCheck(appInfo)
}
drawAdapter.submitList(searchResults)
}
}
}
override fun onVerticalFling(velocityY: Float) {
super.onVerticalFling(velocityY)
// RecyclerView.fling expects the negated pointer velocity
// (the same negation RecyclerView applies to its own
// VelocityTracker value in onTouchEvent).
binding.drawAdapter.fling(0, -velocityY.roundToInt())
}
}
}
private fun navigateToHome(swipe: Constants.Swipe) {
val actionTypeNavOptions: NavOptions? =
if (preferenceHelper.disableAnimations) null
else appHelper.getActionType(swipe)
Handler(Looper.getMainLooper()).post {
findNavController().navigate(
R.id.HomeFragment,
null,
actionTypeNavOptions
)
}
}
/**
* Runs the fuzzy search synchronously over the in-memory index.
*
* Called on every keystroke, including the very first character. This is
* fast enough to run on the main thread — the index holds plain strings
* and a few hundred apps score in well under a millisecond — so results
* appear in the same frame the character is typed.
*/
private fun performSearch(query: String): List<AppInfo> {
val index = searchIndex
val trimmedQuery = query.trim()
if (trimmedQuery.isEmpty()) return index.map { it.app }
// Normalize the query once, not once per app
val normalizedQuery = FuzzyFinder.normalize(trimmedQuery)
val minScore = preferenceHelper.filterStrength
val searchFromStart = preferenceHelper.searchFromStart
val scored = ArrayList<Pair<FuzzyFinder.IndexedApp, Int>>()
for (entry in index) {
val score = FuzzyFinder.score(entry, normalizedQuery)
// Compare against the normalized name, so "fdroid" matches "F-Droid"
val matchesFromStart = !searchFromStart ||
entry.normalizedName.startsWith(normalizedQuery)
if (score > minScore && matchesFromStart) {
scored.add(entry to score)
}
}
// Best score first, alphabetical as a tie breaker
scored.sortWith(
compareByDescending<Pair<FuzzyFinder.IndexedApp, Int>> { it.second }
.thenBy { it.first.app.appName.lowercase() }
)
return scored.map { it.first.app }
}
private fun showSelectedApp(appInfo: AppInfo) {
binding.searchViewText.setQuery("", false)

View File

@@ -14,7 +14,9 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.databinding.ItemDrawBinding
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
class DrawViewHolder(
private val binding: ItemDrawBinding,
@@ -70,7 +72,20 @@ class DrawViewHolder(
}
appDrawIcon.layoutParams = layoutParams
appDrawIcon.setImageDrawable(appNewIcon ?: nonNullDrawable)
val baseIcon: Drawable = appNewIcon ?: nonNullDrawable
appDrawIcon.setImageDrawable(
if (preferenceHelper.showNotificationDots &&
NotificationBadgeService.hasNotifications(
appInfo.userHandle,
appInfo.packageName
)
) {
NotificationDotHelper.withDot(itemView.context, baseIcon)
} else {
baseIcon
}
)
appDrawIcon.visibility = View.VISIBLE
val parentLayout = appDrawName.parent as LinearLayoutCompat

View File

@@ -11,7 +11,9 @@ import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
@@ -28,6 +30,8 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.listener.OnItemMoveListener
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.ui.favorite.FavoriteViewHolder
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@@ -82,6 +86,7 @@ class FavoriteFragment : Fragment(),
setupRecyclerView()
observeFavorite()
observeHomeAppOrder()
observeNotificationBadges()
observeSwipeTouchListener()
}
@@ -128,6 +133,25 @@ class FavoriteFragment : Fragment(),
}
}
private fun observeNotificationBadges() {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
NotificationBadgeService.notificationCounts.collect {
val recyclerView = binding.favoriteAdapter
for (i in 0 until recyclerView.childCount) {
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
if (holder is FavoriteViewHolder) {
val position = holder.bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
holder.bind(favoriteAdapter.currentList[position])
}
}
}
}
}
}
}
private fun observeHomeAppOrder() {
binding.favoriteAdapter.adapter = favoriteAdapter
val listener: OnItemMoveListener.OnItemActionListener = favoriteAdapter

View File

@@ -11,6 +11,8 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.databinding.ItemFavoriteBinding
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
@SuppressLint("ClickableViewAccessibility")
class FavoriteViewHolder(
@@ -48,7 +50,18 @@ class FavoriteViewHolder(
if (preferenceHelper.showAppIcon) {
val appIcon =
binding.root.context.packageManager.getApplicationIcon(appInfo.packageName)
appFavoriteLeftIcon.setImageDrawable(appIcon)
val baseIcon: android.graphics.drawable.Drawable =
if (preferenceHelper.showNotificationDots &&
NotificationBadgeService.hasNotifications(
appInfo.userHandle,
appInfo.packageName
)
) {
NotificationDotHelper.withDot(binding.root.context, appIcon)
} else {
appIcon
}
appFavoriteLeftIcon.setImageDrawable(baseIcon)
appFavoriteLeftIcon.layoutParams.width =
preferenceHelper.appTextSize.toInt() * 3
appFavoriteLeftIcon.layoutParams.height =

View File

@@ -22,9 +22,12 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.widget.AppCompatTextView
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavOptions
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.StaggeredGridLayoutManager
@@ -46,6 +49,7 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
@@ -54,6 +58,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -111,6 +116,13 @@ 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
// this guarantees rendering even if the launcher font is changed.
binding.currentWeather.typeface =
ResourcesCompat.getFont(requireContext(), R.font.jetbrains_mono_nf_weather)
}
@SuppressLint("ClickableViewAccessibility")
@@ -232,6 +244,30 @@ class HomeFragment : Fragment(),
}
}
private fun observeNotificationBadges() {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
NotificationBadgeService.notificationCounts.collect {
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])
}
}
}
}
@SuppressLint("ClickableViewAccessibility", "InflateParams")
private fun observeSwipeTouchListener() {
binding.apply {
@@ -250,6 +286,7 @@ class HomeFragment : Fragment(),
preferenceViewModel.setShowDate(preferenceHelper.showDate)
preferenceViewModel.setShowAlarmClock(preferenceHelper.showAlarmClock)
preferenceViewModel.setShowDailyWord(preferenceHelper.showDailyWord)
preferenceViewModel.setShowCurrentWeather(preferenceHelper.showCurrentWeather)
preferenceViewModel.setShowBattery(preferenceHelper.showBattery)
preferenceViewModel.showTimeLiveData.observe(viewLifecycleOwner) {
@@ -301,6 +338,23 @@ class HomeFragment : Fragment(),
)
}
preferenceViewModel.showCurrentWeatherLiveData.observe(viewLifecycleOwner) {
appHelper.updateUI(
binding.currentWeather,
preferenceHelper.homeDailyWordAlignment,
preferenceHelper.dailyWordColor,
preferenceHelper.dailyWordTextSize,
it
)
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()
@@ -321,6 +375,43 @@ class HomeFragment : Fragment(),
}
}
/**
* Fetches the current temperature from the MET/Yr API and shows it as a
* nerd-font weather glyph plus Celsius value, e.g. "\uE312 16°".
* Formatted like the daily word (same alignment/color/size settings).
*/
private fun loadCurrentWeather() {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
val sharedPreferences =
requireContext().getSharedPreferences(Constants.WEATHER_PREFS, Context.MODE_PRIVATE)
val latitude = sharedPreferences.getFloat(Constants.LATITUDE, 0f)
val longitude = sharedPreferences.getFloat(Constants.LONGITUDE, 0f)
val result = appHelper.fetchMetWeather(requireContext(), latitude, longitude)
withContext(Dispatchers.Main) {
when (result) {
is AppHelper.MetWeatherResult.Success -> {
binding.currentWeather.text = appHelper.buildCurrentWeatherText(
result.temperature,
result.symbolCode,
result.temperature4h,
result.symbol4h,
)
binding.currentWeather.visibility = View.VISIBLE
}
is AppHelper.MetWeatherResult.Failure -> {
// No data (offline, no location): keep any cached text,
// otherwise hide the element.
if (binding.currentWeather.text.isNullOrEmpty()) {
binding.currentWeather.visibility = View.GONE
}
}
}
}
}
}
private fun observeBioAuthCheck(appInfo: AppInfo) {
if (!appInfo.lock)
context.launchApp(appInfo)
@@ -584,6 +675,7 @@ class HomeFragment : Fragment(),
binding.mainView.hideKeyboard()
observeUserInterfaceSettings()
observeFavoriteAppList()
if (preferenceHelper.showCurrentWeather) loadCurrentWeather()
}
override fun onAppClicked(appInfo: AppInfo) {

View File

@@ -15,7 +15,9 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.databinding.ItemHomeBinding
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
import javax.inject.Inject
class HomeViewHolder @Inject constructor(
@@ -70,7 +72,20 @@ class HomeViewHolder @Inject constructor(
}
appHomeIcon.layoutParams = layoutParams
appHomeIcon.setImageDrawable(appNewIcon ?: nonNullDrawable)
val baseIcon: Drawable = appNewIcon ?: nonNullDrawable
appHomeIcon.setImageDrawable(
if (preferenceHelper.showNotificationDots &&
NotificationBadgeService.hasNotifications(
appInfo.userHandle,
appInfo.packageName
)
) {
NotificationDotHelper.withDot(itemView.context, baseIcon)
} else {
baseIcon
}
)
appHomeIcon.visibility = View.VISIBLE
val parentLayout = appHomeName.parent as LinearLayoutCompat

View File

@@ -1,8 +1,11 @@
package com.github.droidworksstudio.launcher.ui.settings
import android.content.Context
import android.content.ComponentName
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
@@ -18,6 +21,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.github.droidworksstudio.common.getAppNameFromPackageName
import com.github.droidworksstudio.common.showLongToast
import com.github.droidworksstudio.launcher.R
import com.github.droidworksstudio.launcher.databinding.FragmentSettingsFeaturesBinding
import com.github.droidworksstudio.launcher.helper.AppHelper
@@ -25,6 +29,7 @@ import com.github.droidworksstudio.launcher.helper.AppReloader
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
import com.github.droidworksstudio.launcher.repository.AppInfoRepository
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -129,6 +134,8 @@ class SettingsFeaturesFragment : Fragment(),
homeAlignmentBottomSwitchCompat.isChecked = preferenceHelper.homeAlignmentBottom
lockSettingsSwitchCompat.isChecked = preferenceHelper.settingsLock
disableAnimationsSwitchCompat.isChecked = preferenceHelper.disableAnimations
showNotificationDotsSwitchCompat.isChecked = preferenceHelper.showNotificationDots
showHomeWidgetsSwitchCompat.isChecked = preferenceHelper.showHomeWidgets
}
}
@@ -450,10 +457,43 @@ class SettingsFeaturesFragment : Fragment(),
val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType)
}
showNotificationDotsSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
preferenceViewModel.setShowNotificationDots(isChecked)
val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType)
if (isChecked && !isNotificationAccessGranted()) {
openNotificationAccessSettings()
}
}
showHomeWidgetsSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
preferenceViewModel.setShowHomeWidgets(isChecked)
val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType)
}
}
}
private fun isNotificationAccessGranted(): Boolean {
val componentName = ComponentName(requireContext(), NotificationBadgeService::class.java)
val notificationManager =
requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
return notificationManager.isNotificationListenerAccessGranted(componentName)
}
private fun openNotificationAccessSettings() {
try {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
} catch (_: Exception) {
requireContext().showLongToast(
getString(R.string.toast_cannot_open_notification_access_settings)
)
}
}
private var swipeActionDialog: AlertDialog? = null
@RequiresApi(Build.VERSION_CODES.Q)

View File

@@ -95,6 +95,7 @@ class SettingsLookFeelFragment : Fragment(),
alarmClockSwitchCompat.isChecked = preferenceHelper.showAlarmClock
dailyWordSwitchCompat.isChecked = preferenceHelper.showDailyWord
appIconsSwitchCompat.isChecked = preferenceHelper.showAppIcon
currentWeatherSwitchCompat.isChecked = preferenceHelper.showCurrentWeather
}
}
@@ -188,6 +189,12 @@ class SettingsLookFeelFragment : Fragment(),
val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType)
}
currentWeatherSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
preferenceViewModel.setShowCurrentWeather(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
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))

View File

@@ -20,6 +20,12 @@ object Constants {
const val WIDGET_BATTERY = "WIDGET_BATTERY"
const val WEATHER_PREFS = "EasyWeather.pref"
const val MET_WEATHER_PREFS = "met_weather_prefs"
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"
@@ -70,6 +76,10 @@ object Constants {
const val HOME_ALLIGNMENT_BOTTOM = "HOME_ALLIGNMENT_BOTTOM"
const val TOGGLE_SETTING_LOCK = "TOGGLE_SETTING_LOCK"
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"

View File

@@ -0,0 +1,57 @@
package com.github.droidworksstudio.launcher.utils
/**
* Maps MET/Yr symbol codes to Nerd Font weather glyphs
* (nf-weather-* codepoints from the Weather Icons font embedded in
* JetBrainsMonoNerdFont). Rendered with R.font.jetbrains_mono_nf_weather.
*/
object MetSymbolMapper {
/**
* Returns the nf-weather glyph char for a MET `symbol_code`
* (e.g. "partlycloudy_day"). Unknown codes fall back to a cloud.
*/
fun glyphFor(symbolCode: String): Char {
val normalized = symbolCode.removeSuffix("_polartwilight")
val night = normalized.endsWith("_night")
val base = normalized
.removeSuffix("_day")
.removeSuffix("_night")
return when (base) {
"clearsky", "fair" -> if (night) '\uE32B' else '\uE30D'
"partlycloudy" -> if (night) '\uE379' else '\uE302'
"cloudy" -> '\uE312'
"fog" -> '\uE313'
"lightrain" -> '\uE31B'
"rain" -> '\uE318'
"heavyrain" -> '\uE319'
"lightrainshowers" -> if (night) '\uE328' else '\uE30B'
"rainshowers" -> if (night) '\uE326' else '\uE309'
"heavyrainshowers" -> if (night) '\uE324' else '\uE307'
"lightrainandthunder" -> '\uE315'
"rainandthunder" -> '\uE31D'
"heavyrainandthunder" -> '\uE31C'
"lightrainshowersandthunder" -> if (night) '\uE322' else '\uE305'
"rainshowersandthunder" -> if (night) '\uE32A' else '\uE30F'
"heavyrainshowersandthunder" -> if (night) '\uE329' else '\uE30E'
"sleet" -> '\uE316'
"lightssleetshowers", "sleetshowers" -> if (night) '\uE323' else '\uE306'
"heavysleetshowers" -> if (night) '\uE364' else '\uE362'
"sleetandthunder",
"lightsleetshowersandthunder",
"sleetshowersandthunder",
-> if (night) '\uE364' else '\uE362'
"lightssnow", "lightsnow", "snow" -> '\uE31A'
"heavysnow" -> '\uE35E'
"lightssnowshowers", "snowshowers" -> if (night) '\uE327' else '\uE30A'
"heavysnowshowers" -> if (night) '\uE361' else '\uE35F'
"snowandthunder",
"snowshowersandthunder",
-> if (night) '\uE367' else '\uE365'
"thunder" -> '\uE31D'
"wind" -> '\uE34B'
else -> '\uE312'
}
}
}

View File

@@ -0,0 +1,40 @@
package com.github.droidworksstudio.launcher.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import com.github.droidworksstudio.common.ColorIconsExtensions
object NotificationDotHelper {
/**
* 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)
val width = source.width
val height = source.height
if (width <= 0 || height <= 0) return icon
val dotRadius = (minOf(width, height) * 0.22f).coerceAtLeast(4f)
// 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)
canvas.drawBitmap(source, 0f, 0f, null)
val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
}
canvas.drawCircle(centerX, centerY, dotRadius, dotPaint)
return BitmapDrawable(context.resources, bitmap)
}
}

View File

@@ -76,6 +76,4 @@ class AppViewModel @Inject constructor(
appInfoRepository.updateInfo(appInfo)
}
}
fun searchAppInfo() = appInfoRepository.searchNote()
}

View File

@@ -49,6 +49,9 @@ class PreferenceViewModel @Inject constructor(
private val autoKeyboardLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val lockSettingsLiveData: MutableLiveData<Boolean> = MutableLiveData()
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()
@@ -297,6 +300,21 @@ class PreferenceViewModel @Inject constructor(
disableAnimationsLiveData.postValue((preferenceHelper.disableAnimations))
}
fun setShowNotificationDots(showNotificationDots: Boolean) {
preferenceHelper.showNotificationDots = showNotificationDots
showNotificationDotsLiveData.postValue((preferenceHelper.showNotificationDots))
}
fun setShowCurrentWeather(showCurrentWeather: Boolean) {
preferenceHelper.showCurrentWeather = showCurrentWeather
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))

Binary file not shown.

View File

@@ -78,6 +78,14 @@
android:textSize="32sp"
android:visibility="gone" />
<TextView
android:id="@+id/currentWeather"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textSize="32sp"
android:visibility="gone" />
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat

View File

@@ -238,6 +238,68 @@
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/showNotificationDots_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_notification_dots"
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/showNotificationDots_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
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

View File

@@ -300,6 +300,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/currentWeather_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_current_weather"
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/currentWeather_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

View File

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

View File

@@ -104,12 +104,24 @@
<string name="settings_display_alarm_clock">Show Alarm Clock</string>
<string name="settings_display_daily_word">Show Daily Word</string>
<string name="settings_display_app_icons">Show App Icons</string>
<string name="settings_display_current_weather">Show Current Weather</string>
<string name="settings_display_automatic_keyboard">Auto Show Keyboard</string>
<string name="settings_display_auto_open_apps">Auto Open Last App</string>
<string name="settings_home_alignment_bottom">Home Alignment Bottom</string>
<string name="settings_search_from_start">Search From Start</string>
<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>
@@ -200,6 +212,7 @@
<string name="accessibility_settings_disable">Disable</string>
<string name="accessibility_service_name">Easy Launcher Actions Service</string>
<string name="notification_badge_service_label">Easy Launcher - notification dots</string>
<string name="accessibility_service_desc">Please turn on accessibility service to use double tap to lock feature in Easy Launcher.\n\nThis permission is used only to turn off your screen. Our
accessibility service does not collect or share any data.</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.

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.3-Signed.apk vendored Normal file

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

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.

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

Binary file not shown.

Binary file not shown.

View File

@@ -14,6 +14,7 @@ dependencyResolutionManagement {
google()
mavenCentral()
maven(url = "https://jitpack.io")
maven(url = "https://gitea.haugesenspil.dk/api/packages/jonas/maven")
}
}