Compare commits
18 Commits
v0.3.3
...
a42f913875
| Author | SHA1 | Date | |
|---|---|---|---|
| a42f913875 | |||
| 1d5a568218 | |||
| daa1748858 | |||
| 09cc6a557d | |||
| da5542690d | |||
| 9799d3057b | |||
| 68ebe26f29 | |||
| 84fdb81061 | |||
| c972125fef | |||
| cd94721fc8 | |||
| 8cb9166f41 | |||
| 010ff8a0c8 | |||
| 5288c980d0 | |||
| 0aaf00988d | |||
| e69796535f | |||
| 16a75a9e7e | |||
| bea62e8d88 | |||
| e81b4e8ac1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ git-cliff*
|
|||||||
/app/build
|
/app/build
|
||||||
/app/debug
|
/app/debug
|
||||||
diff.*
|
diff.*
|
||||||
|
/.pi
|
||||||
|
|||||||
213
CLAUDE.md
Normal file
213
CLAUDE.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# 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 35 / 0.3.5).
|
||||||
|
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.5-Signed.apk \
|
||||||
|
app/build/outputs/apk/withInternet/release/app.easy.launcher_v0.3.5-Release.apk
|
||||||
|
# repeat for withoutInternet -> dist/EasyLauncher-v0.3.5-Signed.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
- Source: `https://api.met.no/weatherapi/locationforecast/2.0/compact` — **no API
|
||||||
|
key**, but REQUIRES a descriptive `User-Agent` header (see `MetApiService`).
|
||||||
|
Verified working from PC and phone network.
|
||||||
|
- `AppHelper.fetchMetWeather(context, lat, lon)` returns Celsius (rounded) +
|
||||||
|
MET `symbol_code`; 15-minute cache in `met_weather_prefs`.
|
||||||
|
- 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
214
WIDGET_HOSTING_TASK.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# Task: Add home-screen app-widget hosting to EasyLauncher
|
||||||
|
|
||||||
|
You are extending the EasyLauncher fork at `~/sources/EasyLauncher` (branch `main`).
|
||||||
|
Goal: let the user place **real Android app widgets** (in particular K-9 Mail's
|
||||||
|
"K-9 Unread" widget) on the launcher home screen, rendered live and persisted
|
||||||
|
across reboots.
|
||||||
|
|
||||||
|
Read `CLAUDE.md` in the repo root first — it is the fork's operating manual
|
||||||
|
(build, signing, on-device testing, quirks). Follow it.
|
||||||
|
|
||||||
|
## Why (background)
|
||||||
|
|
||||||
|
The launcher shows a red dot on app icons for active notifications
|
||||||
|
(`NotificationBadgeService`). K-9 Mail cancels its new-mail notification when
|
||||||
|
the app is opened, so the dot disappears even when unread mail remains. There
|
||||||
|
is no Android API to read another app's unread count. The clean, official
|
||||||
|
workaround is K-9's own **"K-9 Unread" widget**, which shows a persistent
|
||||||
|
unread count that only goes to zero when the mail is read. EasyLauncher
|
||||||
|
currently cannot host third-party app widgets (its "Widgets" page,
|
||||||
|
`WidgetFragment.kt`, is only self-drawn battery/clock widgets). This task adds
|
||||||
|
real app-widget hosting.
|
||||||
|
|
||||||
|
Scope guard: do NOT implement root-based database reading, IMAP polling, or
|
||||||
|
changes to K-9. Widget hosting only.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- Repo: `~/sources/EasyLauncher`, branch `main`, origin = Gitea `jonas/EasyLauncher`.
|
||||||
|
No `upstream` remote. Commit locally with conventional messages (see git
|
||||||
|
history: `feat: ...`, `fix: ...`, `docs: ...`). Do not push, tag, or create
|
||||||
|
a Gitea release unless the user asks.
|
||||||
|
- Stack: Kotlin, minSdk 24, compileSdk/targetSdk 36, Java 17, Hilt (kapt),
|
||||||
|
Room, ViewBinding (+ DataBinding enabled), two product flavors
|
||||||
|
(`withInternet` / `withoutInternet`), R8 enabled in release.
|
||||||
|
- Build (offline, JDK 21):
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=~/jdk21
|
||||||
|
./gradlew :app:compileWithInternetReleaseKotlin :app:compileWithoutInternetReleaseKotlin --offline
|
||||||
|
./gradlew :app:assembleWithInternetRelease :app:assembleWithoutInternetRelease --offline
|
||||||
|
```
|
||||||
|
Outputs: `app/build/outputs/apk/{withInternet,withoutInternet}/release/`.
|
||||||
|
- Signing (for installing on the phone):
|
||||||
|
```bash
|
||||||
|
PASS=$(cat ~/android-keystores/easylauncher-release.pass)
|
||||||
|
~/android-sdk/build-tools/36.0.0/apksigner sign --v4-signing-enabled true \
|
||||||
|
--ks ~/android-keystores/easylauncher-release.jks --ks-key-alias easylauncher \
|
||||||
|
--ks-pass "pass:$PASS" --key-pass "pass:$PASS" \
|
||||||
|
--out /tmp/EasyLauncher-widgethost.apk \
|
||||||
|
app/build/outputs/apk/withInternet/release/app.easy.launcher_v0.3.6-Release.apk
|
||||||
|
```
|
||||||
|
Do NOT bump versionCode/versionName (current 36 / 0.3.6) and do NOT write
|
||||||
|
into `dist/` — this is a feature build, not a release. Installing with the
|
||||||
|
same versionCode works: `adb install -r /tmp/EasyLauncher-widgethost.apk`.
|
||||||
|
- Phone: Unihertz Titan 2 Elite, adb serial `TITAN20000043119`, rooted
|
||||||
|
(Magisk). `app.easy.launcher` (signed release) is installed and is the
|
||||||
|
default home app. K-9 Mail (`com.fsck.k9`) is installed with 3 accounts.
|
||||||
|
Device facts: after a reboot the phone is CE-locked until the user enters
|
||||||
|
the PIN — if something "doesn't work" right after a reboot, ask the user to
|
||||||
|
unlock before debugging.
|
||||||
|
- Screenshots: `adb exec-out screencap -p > /tmp/s.png`; UI dump:
|
||||||
|
`adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml`.
|
||||||
|
Logs: `adb logcat | grep -i widgethost`.
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
### 1. Widget host manager (the core plumbing)
|
||||||
|
|
||||||
|
Create a Hilt `@Singleton` manager (e.g.
|
||||||
|
`app/src/main/java/com/github/droidworksstudio/launcher/service/WidgetHostManager.kt`,
|
||||||
|
or `helper/` if that fits better — follow existing package conventions) that
|
||||||
|
wraps:
|
||||||
|
|
||||||
|
- `AppWidgetHost(context, hostId)` with a unique constant hostId.
|
||||||
|
- `AppWidgetManager.getInstance(context)`.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- **`startListening()` / `stopListening()`** — delegate to `AppWidgetHost`.
|
||||||
|
- **Restore placed widgets** — re-create `AppWidgetHostView`s from persisted
|
||||||
|
widget ids after process death / reboot:
|
||||||
|
- For each stored `(appWidgetId, component)`:
|
||||||
|
- `appWidgetManager.getAppWidgetInfo(id)`; if null (unbound/removed),
|
||||||
|
drop the entry (`host.deleteAppWidget(id)` is not needed for missing
|
||||||
|
info; just clear the record).
|
||||||
|
- Else `val view = host.createView(context, id, info)` and add to the
|
||||||
|
home widget container. `createView` binds the widget; the view updates
|
||||||
|
automatically while the host is listening.
|
||||||
|
- **`requestAddWidget(component, callback)`** — the add flow:
|
||||||
|
1. `val id = host.allocateAppWidgetId()`
|
||||||
|
2. `if (!appWidgetManager.bindAppWidgetIdIfAllowed(id, component))` →
|
||||||
|
the app is not the active launcher; notify the user
|
||||||
|
("Set Easy Launcher as the default home app") and abort.
|
||||||
|
3. If the provider has a configure activity
|
||||||
|
(`providerInfo.configure != null`), launch it with
|
||||||
|
`appWidgetManager.startAppWidgetConfigureActivityForResult(activity, id,
|
||||||
|
REQUEST_ADD_WIDGET, Bundle())` (4-arg version, API 17+, fine for
|
||||||
|
minSdk 24; verify the exact signature against the local SDK — compileSdk
|
||||||
|
36 sources are available). Resume on `onActivityResult`: `RESULT_OK` →
|
||||||
|
continue; otherwise `host.deleteAppWidget(id)` and abort.
|
||||||
|
4. Create and return the bound `AppWidgetHostView`
|
||||||
|
(`host.createView(context, id, info)`).
|
||||||
|
- **`removeWidget(view, id)`** — remove view from container,
|
||||||
|
`host.deleteAppWidget(id)`, clear the persisted record.
|
||||||
|
- **Persistence** — store placed widgets in SharedPreferences (follow
|
||||||
|
`PreferenceHelper` patterns; JSON string of `id|component` pairs, key e.g.
|
||||||
|
`HOSTED_WIDGETS`). Room is acceptable but prefs match the fork's style.
|
||||||
|
- Keep it dependency-light and testable. Log with tag `WidgetHost`.
|
||||||
|
|
||||||
|
### 2. Home screen widget area
|
||||||
|
|
||||||
|
`fragment_home.xml` has a vertical `mainView` LinearLayout:
|
||||||
|
`blockView` (battery/clock/date/weather) on top, `appListTouchArea`
|
||||||
|
(RecyclerView grid) below.
|
||||||
|
|
||||||
|
- Insert a full-width widget container between `blockView` and
|
||||||
|
`appListTouchArea`, e.g. a `FrameLayout` (or vertical `LinearLayout` for
|
||||||
|
stacking) `android:id="@+id/widgetHostArea"`, `visibility="gone"` when no
|
||||||
|
widgets are placed. Keep margins consistent with the rest of the screen
|
||||||
|
(20dp horizontal, mirroring `blockView`).
|
||||||
|
- Wire in `HomeFragment.kt`:
|
||||||
|
- `onStart`: `widgetHostManager.startListening()`; restore placed widgets
|
||||||
|
into the container. `onStop`: `stopListening()`.
|
||||||
|
- Handle the add result (`onActivityResult` REQUEST_ADD_WIDGET) by inserting
|
||||||
|
the returned `AppWidgetHostView`, then persist and show the container.
|
||||||
|
- Removing: long-press a hosted widget → confirm dialog → remove.
|
||||||
|
- "Add widget" affordance when the feature is enabled: a small "+" in the
|
||||||
|
widget area when empty (and/or a settings entry — see below). Do not
|
||||||
|
break existing swipe/gesture handlers (`touchArea`, `appListTouchArea`
|
||||||
|
forwards vertical drags — the new area sits outside `appListTouchArea` so
|
||||||
|
no conflict, but verify touches on the widget still work).
|
||||||
|
- If the widget area is `gone` when empty, make sure the home grid layout
|
||||||
|
does not jump when the first widget is added.
|
||||||
|
|
||||||
|
### 3. Widget picker
|
||||||
|
|
||||||
|
A bottom sheet or `AlertDialog` listing installed widget providers:
|
||||||
|
- `appWidgetManager.getInstalledProviders()` (deprecated on API 33+ — use the
|
||||||
|
profile variant `getInstalledProvidersForProfile(UserHandle.CURRENT)` on
|
||||||
|
API 33+, guard with `Build.VERSION`). The app already has
|
||||||
|
`QUERY_ALL_PACKAGES`, so all providers are visible.
|
||||||
|
- Each row: `providerInfo.loadIcon(context, density)` + label; sort by label.
|
||||||
|
- Tapping a row → `widgetHostManager.requestAddWidget(...)` flow above.
|
||||||
|
- Opening the picker: add a settings entry (see 4). Optionally also a
|
||||||
|
long-press on empty home space if the launcher has no competing handler —
|
||||||
|
check first; if home long-press is already used, use settings only.
|
||||||
|
|
||||||
|
### 4. Settings
|
||||||
|
|
||||||
|
Follow the existing "Notification Dots" toggle pattern
|
||||||
|
(`SHOW_NOTIFICATION_DOTS` in `PreferenceHelper` +
|
||||||
|
`SettingsFeaturesFragment`):
|
||||||
|
- New pref `SHOW_HOME_WIDGETS` (default OFF).
|
||||||
|
- Toggle: "Home screen widgets".
|
||||||
|
- When ON, expose "Add widget" (opens the picker) — e.g. a secondary row or
|
||||||
|
the "+" on the home widget area.
|
||||||
|
- Add string resources in `values/strings.xml` only (the app is on Crowdin —
|
||||||
|
do not invent translations in other locales).
|
||||||
|
- Pref caveat from CLAUDE.md: don't re-push the value from
|
||||||
|
`observeUserInterfaceSettings` in a way that clobbers the on-disk value
|
||||||
|
(see the `SHOW_NOTIFICATION_DOTS` note).
|
||||||
|
|
||||||
|
### 5. Permissions
|
||||||
|
|
||||||
|
None new. Do NOT add `android.permission.BIND_APPWIDGET` (host role does not
|
||||||
|
need it). `QUERY_ALL_PACKAGES` already covers provider enumeration.
|
||||||
|
|
||||||
|
### 6. Documentation
|
||||||
|
|
||||||
|
Update `CLAUDE.md` in the repo root with a short section on the widget host
|
||||||
|
(service/manager name, pref key, how to place/remove a widget via adb if
|
||||||
|
useful, known quirks), matching the fork's existing documentation style.
|
||||||
|
|
||||||
|
## Acceptance criteria (verify on the phone)
|
||||||
|
|
||||||
|
1. Both flavors compile offline.
|
||||||
|
2. Signed `withInternet` APK installs in place over the current
|
||||||
|
`app.easy.launcher` (same signature, same versionCode 36) — `adb install -r`.
|
||||||
|
3. Settings → Features shows the new "Home screen widgets" toggle; enabling
|
||||||
|
it surfaces "Add widget".
|
||||||
|
4. The picker lists "K-9 Unread" (and other installed widgets). If K-9's
|
||||||
|
unread widget is missing, check `adb shell dumpsys package com.fsck.k9 |
|
||||||
|
grep -B2 -A6 UnreadWidgetProvider` — K-9's widget receivers are enabled
|
||||||
|
programmatically (`android:enabled` from a manifest bool); if disabled,
|
||||||
|
find the K-9 setting that enables home-screen widgets (ask the user if
|
||||||
|
unclear) or `adb shell pm enable
|
||||||
|
com.fsck.k9/com.fsck.k9.provider.UnreadWidgetProvider`.
|
||||||
|
5. Placing the K-9 Unread widget runs K-9's configuration (account picker),
|
||||||
|
then the widget renders on the home screen showing a live unread count
|
||||||
|
(currently 11 unread across the user's 3 accounts — a per-account widget
|
||||||
|
shows that account's count). Reading mail in K-9 decreases it; the dot
|
||||||
|
question is irrelevant here because the widget keeps counting until 0.
|
||||||
|
6. Widget survives: app restart (force-stop + relaunch via HOME), and a phone
|
||||||
|
reboot (after the user re-enters their PIN — CE-lock).
|
||||||
|
7. Long-press → remove works; after removal the area is hidden again and the
|
||||||
|
widget id is gone from prefs.
|
||||||
|
8. Regression: notification dots still appear (see CLAUDE.md validation
|
||||||
|
trick), home grid/weather/drawer unchanged, no crash on rotation.
|
||||||
|
9. Screenshot proof: `adb exec-out screencap -p > /tmp/widget-proof.png`
|
||||||
|
showing the K-9 Unread widget on the home screen. Keep the file and report
|
||||||
|
its path.
|
||||||
|
10. No leaked `AppWidgetHostView`s: `stopListening` on `onStop`, views
|
||||||
|
removed on removal. Watch `logcat` for host-related warnings.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
- Working feature per above, committed as `feat: host app widgets on home
|
||||||
|
screen (K-9 unread widget)` (or similar) — commit locally, do not push.
|
||||||
|
- `CLAUDE.md` updated.
|
||||||
|
- Verification evidence: build logs, screenshot path, list of things tested
|
||||||
|
on the phone and their outcome.
|
||||||
|
|
||||||
|
Report back concisely: what you built (files + key decisions), what you
|
||||||
|
verified on the phone, and anything the user must do (e.g. K-9 widget
|
||||||
|
setting, PIN unlock after reboot).
|
||||||
@@ -19,8 +19,8 @@ android {
|
|||||||
applicationId = "app.easy.launcher"
|
applicationId = "app.easy.launcher"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 33
|
versionCode = 37
|
||||||
versionName = "0.3.3"
|
versionName = "0.3.7"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
manifestPlaceholders["internetPermission"] = "android.permission.INTERNET"
|
manifestPlaceholders["internetPermission"] = "android.permission.INTERNET"
|
||||||
@@ -187,6 +187,7 @@ dependencies {
|
|||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation(libs.retrofit)
|
implementation(libs.retrofit)
|
||||||
implementation(libs.converter.gson)
|
implementation(libs.converter.gson)
|
||||||
|
implementation("dk.haugesenspil:met-weather:0.1.0")
|
||||||
implementation(libs.constraintlayout)
|
implementation(libs.constraintlayout)
|
||||||
implementation(libs.navigation.fragment.ktx)
|
implementation(libs.navigation.fragment.ktx)
|
||||||
implementation(libs.navigation.ui.ktx)
|
implementation(libs.navigation.ui.ktx)
|
||||||
|
|||||||
@@ -103,6 +103,15 @@
|
|||||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.provider"
|
android:authorities="${applicationId}.provider"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,65 +2,133 @@ package com.github.droidworksstudio.fuzzywuzzy
|
|||||||
|
|
||||||
import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
||||||
import java.text.Normalizer
|
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 {
|
object FuzzyFinder {
|
||||||
fun scoreApp(app: AppInfo, searchChars: String, topScore: Int): Int {
|
|
||||||
val appChars = app.appName
|
|
||||||
|
|
||||||
val fuzzyScore = calculateFuzzyScore(
|
/** One app with its precomputed search data. */
|
||||||
normalizeString(appChars),
|
class IndexedApp(val app: AppInfo) {
|
||||||
normalizeString(searchChars)
|
/** 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 {
|
/** Precomputes search data for a list of apps. Call once per app list change. */
|
||||||
return (appLabel.contains(searchChars, true) or
|
fun buildIndex(apps: List<AppInfo>): List<IndexedApp> = apps.map(::IndexedApp)
|
||||||
Normalizer.normalize(appLabel, Normalizer.Form.NFD)
|
|
||||||
.replace(Regex("\\p{InCombiningDiacriticalMarks}+"), "")
|
/** Normalizes a name or a query: uppercase, no diacritics, no separators. */
|
||||||
.replace(Regex("[-_+,. ]"), "")
|
fun normalize(input: String): String {
|
||||||
.contains(searchChars, true))
|
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 {
|
private fun wordStartsOf(original: String, normalized: String): BooleanArray {
|
||||||
// Remove diacritical marks and special characters, and convert to uppercase
|
val nfd = Normalizer.normalize(original, Normalizer.Form.NFD)
|
||||||
return input
|
val starts = BooleanArray(normalized.length)
|
||||||
.uppercase(Locale.getDefault())
|
var atWordStart = true
|
||||||
.replace(Regex("[\\p{InCombiningDiacriticalMarks}-_+,.]"), "")
|
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 {
|
Character.getType(ch) == Character.NON_SPACING_MARK.toInt() -> {
|
||||||
val m = s1.length
|
// Combining mark left over from NFD (the accent of "é") — ignore.
|
||||||
val n = s2.length
|
}
|
||||||
var matchCount = 0
|
|
||||||
var s1Index = 0
|
|
||||||
|
|
||||||
// Iterate over each character in s2 and check if it exists in s1
|
else -> {
|
||||||
for (c2 in s2) {
|
// Separator: the next letter begins a new word.
|
||||||
var found = false
|
atWordStart = true
|
||||||
|
prevWasLower = 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return starts
|
||||||
|
}
|
||||||
|
|
||||||
// If the current character in s2 is not found in s1, return a score of 0
|
/**
|
||||||
if (!found) {
|
* Scores [entry] against an already normalized [query] (see [normalize]).
|
||||||
return 0f
|
*
|
||||||
}
|
* 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
|
var nameIndex = 0
|
||||||
matchCount++
|
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
|
if (firstMatch == 0 && qLen == nLen) return 100 // exact match
|
||||||
return matchCount.toFloat() / maxOf(m, n)
|
|
||||||
|
// 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.github.droidworksstudio.launcher.adapter.drawer
|
package com.github.droidworksstudio.launcher.adapter.drawer
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
@@ -49,10 +48,4 @@ class DrawAdapter(
|
|||||||
override fun areContentsTheSame(oldItem: AppInfo, newItem: AppInfo) =
|
override fun areContentsTheSame(oldItem: AppInfo, newItem: AppInfo) =
|
||||||
oldItem == newItem
|
oldItem == newItem
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
fun updateDataWithStateFlow(newData: List<AppInfo>) {
|
|
||||||
submitList(newData.toMutableList())
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -49,9 +49,6 @@ interface AppInfoDAO {
|
|||||||
@Query("SELECT * FROM app WHERE is_lock = 1 ORDER BY app_order ASC")
|
@Query("SELECT * FROM app WHERE is_lock = 1 ORDER BY app_order ASC")
|
||||||
fun getLockAppsFlow(): Flow<List<AppInfo>>
|
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
|
@Update
|
||||||
suspend fun updateAppInfo(appInfo: AppInfo)
|
suspend fun updateAppInfo(appInfo: AppInfo)
|
||||||
|
|
||||||
|
|||||||
@@ -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.data.entities.AppInfo
|
||||||
import com.github.droidworksstudio.launcher.helper.weather.WeatherResponse
|
import com.github.droidworksstudio.launcher.helper.weather.WeatherResponse
|
||||||
import com.github.droidworksstudio.launcher.utils.Constants
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
|
import com.github.droidworksstudio.launcher.utils.MetSymbolMapper
|
||||||
import com.github.droidworksstudio.launcher.utils.WeatherApiService
|
import com.github.droidworksstudio.launcher.utils.WeatherApiService
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.JsonSyntaxException
|
import com.google.gson.JsonSyntaxException
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import com.google.gson.stream.JsonReader
|
import com.google.gson.stream.JsonReader
|
||||||
|
import dk.haugesenspil.metweather.MetWeatherClient
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
@@ -53,6 +56,11 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
class AppHelper @Inject constructor() {
|
class AppHelper @Inject constructor() {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** MET requires a descriptive User-Agent identifying the app + contact. */
|
||||||
|
const val MET_USER_AGENT = "app.easy.launcher (https://gitea.haugesenspil.dk/jonas/EasyLauncher)"
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("WrongConstant", "PrivateApi")
|
@SuppressLint("WrongConstant", "PrivateApi")
|
||||||
fun expandNotificationDrawer(context: Context) {
|
fun expandNotificationDrawer(context: Context) {
|
||||||
try {
|
try {
|
||||||
@@ -264,7 +272,9 @@ class AppHelper @Inject constructor() {
|
|||||||
if (nextAlarmClock == null) return "No alarm is set."
|
if (nextAlarmClock == null) return "No alarm is set."
|
||||||
|
|
||||||
val alarmTime = nextAlarmClock.triggerTime
|
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 drawable = AppCompatResources.getDrawable(context, R.drawable.ic_alarm_clock)
|
||||||
val fontSize = TypedValue.applyDimension(
|
val fontSize = TypedValue.applyDimension(
|
||||||
@@ -550,4 +560,73 @@ class AppHelper @Inject constructor() {
|
|||||||
|
|
||||||
// Data class to hold cached weather data along with timestamp
|
// Data class to hold cached weather data along with timestamp
|
||||||
data class CachedWeatherData(val timestamp: Long, val weatherResponse: WeatherResponse)
|
data class CachedWeatherData(val timestamp: Long, val weatherResponse: WeatherResponse)
|
||||||
|
|
||||||
|
sealed class MetWeatherResult {
|
||||||
|
data class Success(val temperature: Int, val symbolCode: String) : 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 result = MetWeatherResult.Success(
|
||||||
|
weather.temperatureC.roundToInt(),
|
||||||
|
weather.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: nerd-font weather glyph followed
|
||||||
|
* by the temperature in Celsius, e.g. "\uE312 16°".
|
||||||
|
*/
|
||||||
|
fun buildCurrentWeatherText(temperature: Int, symbolCode: String): String {
|
||||||
|
return "${MetSymbolMapper.glyphFor(symbolCode)} $temperature°"
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
.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"
|
||||||
|
return timestamp to MetWeatherResult.Success(temperature, symbol)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,18 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
|
|||||||
get() = prefs.getBoolean(Constants.DISABLE_ANIMATIONS, false)
|
get() = prefs.getBoolean(Constants.DISABLE_ANIMATIONS, false)
|
||||||
set(value) = prefs.edit().putBoolean(Constants.DISABLE_ANIMATIONS, value).apply()
|
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
|
var searchEngines: Constants.SearchEngines
|
||||||
get() {
|
get() {
|
||||||
return try {
|
return try {
|
||||||
@@ -244,7 +256,7 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
|
|||||||
set(value) = prefs.edit().putString(Constants.LAUNCHER_FONT, value.name).apply()
|
set(value) = prefs.edit().putString(Constants.LAUNCHER_FONT, value.name).apply()
|
||||||
|
|
||||||
var swipeUpAction: Constants.Action
|
var swipeUpAction: Constants.Action
|
||||||
get() = loadAction(Constants.SWIPE_UP_ACTION, Constants.Action.ShowRecents)
|
get() = loadAction(Constants.SWIPE_UP_ACTION, Constants.Action.ShowWidgets)
|
||||||
set(value) = storeAction(Constants.SWIPE_UP_ACTION, value)
|
set(value) = storeAction(Constants.SWIPE_UP_ACTION, value)
|
||||||
|
|
||||||
var swipeDownAction: Constants.Action
|
var swipeDownAction: Constants.Action
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ internal open class OnSwipeTouchListener(context: Context?, private val preferen
|
|||||||
}
|
}
|
||||||
// Vertical swipe
|
// Vertical swipe
|
||||||
else {
|
else {
|
||||||
|
onVerticalScroll(distanceY)
|
||||||
if (abs(diffY) > swipeThreshold && abs(distanceY) > swipeScrollThreshold) {
|
if (abs(diffY) > swipeThreshold && abs(distanceY) > swipeScrollThreshold) {
|
||||||
if (diffY > 0) onSwipeDown() else onSwipeUp()
|
if (diffY > 0) onSwipeDown() else onSwipeUp()
|
||||||
}
|
}
|
||||||
@@ -115,6 +116,7 @@ internal open class OnSwipeTouchListener(context: Context?, private val preferen
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (abs(diffY) > swipeThreshold && abs(velocityY) > swipeVelocityThreshold) {
|
if (abs(diffY) > swipeThreshold && abs(velocityY) > swipeVelocityThreshold) {
|
||||||
|
onVerticalFling(velocityY)
|
||||||
if (diffY < 0) onSwipeUp() else onSwipeDown()
|
if (diffY < 0) onSwipeUp() else onSwipeDown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +131,21 @@ internal open class OnSwipeTouchListener(context: Context?, private val preferen
|
|||||||
open fun onSwipeLeft() {}
|
open fun onSwipeLeft() {}
|
||||||
open fun onSwipeUp() {}
|
open fun onSwipeUp() {}
|
||||||
open fun onSwipeDown() {}
|
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 onLongClick() {}
|
||||||
open fun onDoubleClick() {}
|
open fun onDoubleClick() {}
|
||||||
open fun onTripleClick() {}
|
open fun onTripleClick() {}
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ class AppInfoRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun searchNote(): Flow<List<AppInfo>> {
|
|
||||||
return appDao.searchApps()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateFavoriteAppInfo(appInfo: AppInfo) = withContext(Dispatchers.IO) {
|
suspend fun updateFavoriteAppInfo(appInfo: AppInfo) = withContext(Dispatchers.IO) {
|
||||||
|
|
||||||
if (appInfo.favorite) {
|
if (appInfo.favorite) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package com.github.droidworksstudio.launcher.service
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.appwidget.AppWidgetHost
|
||||||
|
import android.appwidget.AppWidgetHostView
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.appwidget.AppWidgetProviderInfo
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Process
|
||||||
|
import android.os.UserManager
|
||||||
|
import android.util.Log
|
||||||
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import org.json.JSONArray
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts real Android app widgets (e.g. K-9 Mail's "K-9 Unread") on the launcher
|
||||||
|
* home screen. This is the launcher's [AppWidgetHost]: it allocates/binds widget
|
||||||
|
* ids, recreates [AppWidgetHostView]s after a process restart or reboot, and
|
||||||
|
* persists the placed widgets across sessions.
|
||||||
|
*
|
||||||
|
* Placed widgets are stored as a list of `[appWidgetId, flattenedComponent]`
|
||||||
|
* pairs in SharedPreferences under [Constants.HOSTED_WIDGETS]. The widget ids
|
||||||
|
* belong to this launcher's host; views are re-created from those ids on every
|
||||||
|
* home-screen visit. This host does NOT need BIND_APPWIDGET permission — only
|
||||||
|
* the app that the user sets as the default home can bind widgets, which is how
|
||||||
|
* Android enforces the launcher role.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class WidgetHostManager @Inject constructor(@ApplicationContext private val context: Context) {
|
||||||
|
|
||||||
|
private val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
|
private val host = AppWidgetHost(context, HOST_ID)
|
||||||
|
private val prefs = context.getSharedPreferences(Constants.PACKAGE_PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/** A single placed widget, as recorded in prefs. */
|
||||||
|
private data class WidgetRecord(
|
||||||
|
val appWidgetId: Int,
|
||||||
|
val providerFlattened: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "WidgetHost"
|
||||||
|
|
||||||
|
/** Arbitrary but unique per-host id for this launcher package. */
|
||||||
|
private const val HOST_ID = 0x4554 // "ET" -- Easy Launcher widget host
|
||||||
|
|
||||||
|
/** Request code that MainActivity uses to receive the widget configure result. */
|
||||||
|
const val REQUEST_ADD_WIDGET = 4001
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin receiving app-widget update broadcasts for the placed widgets. */
|
||||||
|
fun startListening() {
|
||||||
|
try {
|
||||||
|
host.startListening()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "startListening failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop receiving app-widget updates (call from onStop). */
|
||||||
|
fun stopListening() {
|
||||||
|
try {
|
||||||
|
host.stopListening()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "stopListening failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All installed widget providers, sorted by label. Uses the profile-aware
|
||||||
|
* variant on API 33+ where the old [AppWidgetManager.getInstalledProviders]
|
||||||
|
* is deprecated.
|
||||||
|
*/
|
||||||
|
fun installedProviders(): List<AppWidgetProviderInfo> {
|
||||||
|
val all = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
appWidgetManager.getInstalledProvidersForProfile(Process.myUserHandle())
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
appWidgetManager.getInstalledProviders()
|
||||||
|
}
|
||||||
|
return all.sortedBy { it.loadLabel(context.packageManager)?.toString() ?: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-create [AppWidgetHostView]s for every widget persisted in prefs, ready
|
||||||
|
* to be added to the home widget container. Entries whose widget id no
|
||||||
|
* longer resolves to a live provider are pruned, but only once the user is
|
||||||
|
* unlocked — right after a reboot the phone is still CE-locked and widget
|
||||||
|
* state is not yet visible, so we must not drop persisted widgets then.
|
||||||
|
*/
|
||||||
|
fun restoreWidgetViews(): List<AppWidgetHostView> {
|
||||||
|
val records = readWidgetRecords()
|
||||||
|
if (records.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val views = mutableListOf<AppWidgetHostView>()
|
||||||
|
val dropped = mutableListOf<WidgetRecord>()
|
||||||
|
for (rec in records) {
|
||||||
|
val info = appWidgetManager.getAppWidgetInfo(rec.appWidgetId)
|
||||||
|
if (info == null) {
|
||||||
|
if (isUserUnlocked()) {
|
||||||
|
Log.w(TAG, "restore: widget ${rec.appWidgetId} no longer bound -- dropping")
|
||||||
|
dropped += rec
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "restore: widget ${rec.appWidgetId} unavailable while locked -- skipped")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
views += host.createView(context, rec.appWidgetId, info)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "restore: createView failed for ${rec.appWidgetId}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dropped.isNotEmpty()) {
|
||||||
|
val remaining = records - dropped.toSet()
|
||||||
|
writeWidgetRecords(remaining)
|
||||||
|
}
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of a widget-add request, reported back to the caller. */
|
||||||
|
sealed interface AddResult {
|
||||||
|
/** Widget bound and its view is ready to be placed on the home screen. */
|
||||||
|
data class Bound(val view: AppWidgetHostView) : AddResult
|
||||||
|
|
||||||
|
/** The package is not the active home app, so binding was refused. */
|
||||||
|
object BindDenied : AddResult
|
||||||
|
|
||||||
|
/** The user cancelled the provider's configure activity. */
|
||||||
|
object Cancelled : AddResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the add-widget flow for [provider].
|
||||||
|
*
|
||||||
|
* - Allocates a new widget id and binds it. Binding fails when this
|
||||||
|
* package is not the active home app; [callback] then receives
|
||||||
|
* [AddResult.BindDenied].
|
||||||
|
* - If the provider has a configure activity, it is launched
|
||||||
|
* (result arrives via [onConfigureResult]) and the callback is deferred
|
||||||
|
* until the user confirms.
|
||||||
|
* - Otherwise a fully bound [AppWidgetHostView] is created immediately and
|
||||||
|
* returned through [callback].
|
||||||
|
*/
|
||||||
|
fun requestAddWidget(
|
||||||
|
activity: Activity,
|
||||||
|
provider: ComponentName,
|
||||||
|
callback: (AddResult) -> Unit,
|
||||||
|
) {
|
||||||
|
val appWidgetId = host.allocateAppWidgetId()
|
||||||
|
if (!appWidgetManager.bindAppWidgetIdIfAllowed(appWidgetId, provider)) {
|
||||||
|
Log.w(TAG, "bind not allowed for $provider -- not the active home app?")
|
||||||
|
host.deleteAppWidgetId(appWidgetId)
|
||||||
|
callback(AddResult.BindDenied)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val info = appWidgetManager.getAppWidgetInfo(appWidgetId)
|
||||||
|
if (info == null) {
|
||||||
|
host.deleteAppWidgetId(appWidgetId)
|
||||||
|
callback(AddResult.BindDenied)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.configure != null) {
|
||||||
|
// Defer: launch the provider's configure activity; finish later in
|
||||||
|
// onConfigureResult (MainActivity.onActivityResult).
|
||||||
|
pendingAdd = PendingAdd(appWidgetId, provider)
|
||||||
|
addCallback = callback
|
||||||
|
try {
|
||||||
|
host.startAppWidgetConfigureActivityForResult(
|
||||||
|
activity,
|
||||||
|
appWidgetId,
|
||||||
|
0,
|
||||||
|
REQUEST_ADD_WIDGET,
|
||||||
|
Bundle()
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "startAppWidgetConfigureActivityForResult failed", e)
|
||||||
|
host.deleteAppWidgetId(appWidgetId)
|
||||||
|
pendingAdd = null
|
||||||
|
addCallback = null
|
||||||
|
callback(AddResult.Cancelled)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val view = host.createView(context, appWidgetId, info)
|
||||||
|
persistNewWidget(appWidgetId, provider)
|
||||||
|
callback(AddResult.Bound(view))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from MainActivity.onActivityResult when the configure activity
|
||||||
|
* launched by [requestAddWidget] returns. On OK the widget is bound, the
|
||||||
|
* view is created and the deferred callback fires with [AddResult.Bound]; on
|
||||||
|
* cancel the allocated id is released and [AddResult.Cancelled] fires.
|
||||||
|
*/
|
||||||
|
fun onConfigureResult(resultCode: Int) {
|
||||||
|
val pending = pendingAdd ?: return
|
||||||
|
val callback = addCallback
|
||||||
|
pendingAdd = null
|
||||||
|
addCallback = null
|
||||||
|
|
||||||
|
if (resultCode != Activity.RESULT_OK || callback == null) {
|
||||||
|
host.deleteAppWidgetId(pending.appWidgetId)
|
||||||
|
callback?.invoke(AddResult.Cancelled)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val info = appWidgetManager.getAppWidgetInfo(pending.appWidgetId)
|
||||||
|
if (info == null) {
|
||||||
|
host.deleteAppWidgetId(pending.appWidgetId)
|
||||||
|
callback(AddResult.BindDenied)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val view = host.createView(context, pending.appWidgetId, info)
|
||||||
|
persistNewWidget(pending.appWidgetId, pending.provider)
|
||||||
|
callback(AddResult.Bound(view))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a hosted widget: detach its view, release the host widget id and
|
||||||
|
* drop the persisted record.
|
||||||
|
*/
|
||||||
|
fun removeWidget(view: AppWidgetHostView, appWidgetId: Int) {
|
||||||
|
(view.parent as? android.view.ViewGroup)?.removeView(view)
|
||||||
|
host.deleteAppWidgetId(appWidgetId)
|
||||||
|
val remaining = readWidgetRecords().filterNot { it.appWidgetId == appWidgetId }
|
||||||
|
writeWidgetRecords(remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of widgets currently persisted (for showing/hiding the area). */
|
||||||
|
fun placedWidgetCount(): Int = readWidgetRecords().size
|
||||||
|
|
||||||
|
private var pendingAdd: PendingAdd? = null
|
||||||
|
private var addCallback: ((AddResult) -> Unit)? = null
|
||||||
|
|
||||||
|
private data class PendingAdd(val appWidgetId: Int, val provider: ComponentName)
|
||||||
|
|
||||||
|
private fun persistNewWidget(appWidgetId: Int, provider: ComponentName) {
|
||||||
|
val records = readWidgetRecords().apply {
|
||||||
|
removeAll { it.appWidgetId == appWidgetId }
|
||||||
|
}
|
||||||
|
records += WidgetRecord(appWidgetId, provider.flattenToString())
|
||||||
|
writeWidgetRecords(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readWidgetRecords(): MutableList<WidgetRecord> {
|
||||||
|
val json = prefs.getString(Constants.HOSTED_WIDGETS, null) ?: return mutableListOf()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
val out = mutableListOf<WidgetRecord>()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val pair = arr.getJSONArray(i)
|
||||||
|
out += WidgetRecord(pair.getInt(0), pair.getString(1))
|
||||||
|
}
|
||||||
|
out
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "failed to parse hosted widgets", e)
|
||||||
|
mutableListOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeWidgetRecords(records: List<WidgetRecord>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
records.forEach { rec ->
|
||||||
|
arr.put(JSONArray().apply { put(rec.appWidgetId); put(rec.providerFlattened) })
|
||||||
|
}
|
||||||
|
prefs.edit().putString(Constants.HOSTED_WIDGETS, arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUserUnlocked(): Boolean {
|
||||||
|
return try {
|
||||||
|
context.getSystemService(UserManager::class.java)?.isUserUnlocked ?: true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import com.github.droidworksstudio.launcher.helper.AppHelper
|
|||||||
import com.github.droidworksstudio.launcher.helper.AppReloader
|
import com.github.droidworksstudio.launcher.helper.AppReloader
|
||||||
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
||||||
import com.github.droidworksstudio.launcher.repository.AppInfoRepository
|
import com.github.droidworksstudio.launcher.repository.AppInfoRepository
|
||||||
|
import com.github.droidworksstudio.launcher.service.WidgetHostManager
|
||||||
import com.github.droidworksstudio.launcher.utils.Constants
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
||||||
import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel
|
import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel
|
||||||
@@ -77,6 +78,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var appDao: AppInfoDAO
|
lateinit var appDao: AppInfoDAO
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var widgetHostManager: WidgetHostManager
|
||||||
|
|
||||||
private lateinit var sharedPreferences: SharedPreferences
|
private lateinit var sharedPreferences: SharedPreferences
|
||||||
private lateinit var handler: Handler
|
private lateinit var handler: Handler
|
||||||
|
|
||||||
@@ -413,6 +417,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
|
||||||
|
// Widget configure activity finished; hand off to the widget host before
|
||||||
|
// the generic error handling below (which would toast on cancel).
|
||||||
|
if (requestCode == WidgetHostManager.REQUEST_ADD_WIDGET) {
|
||||||
|
widgetHostManager.onConfigureResult(resultCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (resultCode != RESULT_OK) {
|
if (resultCode != RESULT_OK) {
|
||||||
applicationContext.showLongToast("Intent Error")
|
applicationContext.showLongToast("Intent Error")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
|||||||
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
||||||
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
||||||
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
||||||
|
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
|
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
|
||||||
import com.github.droidworksstudio.launcher.utils.Constants
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
||||||
@@ -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
|
private lateinit var context: Context
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
@@ -104,6 +112,7 @@ class DrawFragment : Fragment(),
|
|||||||
observeClickListener()
|
observeClickListener()
|
||||||
observeSwipeTouchListener()
|
observeSwipeTouchListener()
|
||||||
observeScrollTouchListener()
|
observeScrollTouchListener()
|
||||||
|
observeNotificationBadges()
|
||||||
|
|
||||||
// Initialize observation of drawer apps
|
// Initialize observation of drawer apps
|
||||||
observeDrawerApps()
|
observeDrawerApps()
|
||||||
@@ -151,6 +160,30 @@ class DrawFragment : Fragment(),
|
|||||||
setHasFixedSize(false)
|
setHasFixedSize(false)
|
||||||
layoutManager = StaggeredGridLayoutManager(1, StaggeredGridLayoutManager.VERTICAL)
|
layoutManager = StaggeredGridLayoutManager(1, StaggeredGridLayoutManager.VERTICAL)
|
||||||
isNestedScrollingEnabled = false
|
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) {
|
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||||
// Collect the drawer apps from the ViewModel
|
// Collect the drawer apps from the ViewModel
|
||||||
viewModel.drawApps.collect { apps ->
|
viewModel.drawApps.collect { apps ->
|
||||||
// Update the adapter with the new list of apps
|
// Rebuild the search index from the fresh app list
|
||||||
drawAdapter.submitList(apps)
|
searchIndex = FuzzyFinder.buildIndex(apps)
|
||||||
// Update the adapter's data with the new state flow
|
// Keep whatever search results are currently shown, or show the full list
|
||||||
drawAdapter.updateDataWithStateFlow(apps)
|
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("!")
|
val searchQuery = trimmedQuery.substringAfter("!")
|
||||||
requireContext().searchCustomSearchEngine(preferenceHelper, searchQuery)
|
requireContext().searchCustomSearchEngine(preferenceHelper, searchQuery)
|
||||||
} else {
|
} else {
|
||||||
searchApp(trimmedQuery, false)
|
val results = performSearch(trimmedQuery)
|
||||||
return true // Exit the function
|
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 {
|
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
|
return true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -213,7 +264,7 @@ class DrawFragment : Fragment(),
|
|||||||
binding.apply {
|
binding.apply {
|
||||||
mainView.setOnTouchListener(getSwipeGestureListener(context))
|
mainView.setOnTouchListener(getSwipeGestureListener(context))
|
||||||
touchArea.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) {
|
return object : OnSwipeTouchListener(context, preferenceHelper) {
|
||||||
override fun onSwipeLeft() {
|
override fun onSwipeLeft() {
|
||||||
super.onSwipeLeft()
|
super.onSwipeLeft()
|
||||||
val actionTypeNavOptions: NavOptions? =
|
navigateToHome(Constants.Swipe.Left)
|
||||||
if (preferenceHelper.disableAnimations) null
|
|
||||||
else appHelper.getActionType(Constants.Swipe.Left)
|
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
|
||||||
findNavController().navigate(
|
|
||||||
R.id.HomeFragment,
|
|
||||||
null,
|
|
||||||
actionTypeNavOptions
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSwipeRight() {
|
override fun onSwipeRight() {
|
||||||
super.onSwipeRight()
|
super.onSwipeRight()
|
||||||
val actionTypeNavOptions: NavOptions? =
|
navigateToHome(Constants.Swipe.Right)
|
||||||
if (preferenceHelper.disableAnimations) null
|
|
||||||
else appHelper.getActionType(Constants.Swipe.Right)
|
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
|
||||||
findNavController().navigate(
|
|
||||||
R.id.HomeFragment,
|
|
||||||
null,
|
|
||||||
actionTypeNavOptions
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun searchApp(query: String, isSearching: Boolean) {
|
/**
|
||||||
// Launch a coroutine tied to the lifecycle of the view
|
* Swipe listener for the app list area.
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
*
|
||||||
// Repeat the block when the lifecycle is at least CREATED
|
* Behaves like [getSwipeGestureListener] (left/right swipes navigate
|
||||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
* home), but vertical drags and flings over the empty space around the
|
||||||
val trimmedQuery = query.trim()
|
* 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
|
override fun onSwipeRight() {
|
||||||
viewModel.searchAppInfo().collect { searchResults ->
|
super.onSwipeRight()
|
||||||
// Filter and score results using FuzzyFinder
|
navigateToHome(Constants.Swipe.Right)
|
||||||
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
|
|
||||||
|
|
||||||
// Applying additional filtering based on preferences
|
override fun onVerticalScroll(distanceY: Float) {
|
||||||
val scoredApps = filteredResults.toMap()
|
super.onVerticalScroll(distanceY)
|
||||||
|
binding.drawAdapter.scrollBy(0, distanceY.roundToInt())
|
||||||
|
}
|
||||||
|
|
||||||
val finalResults = if (preferenceHelper.filterStrength >= 1) {
|
override fun onVerticalFling(velocityY: Float) {
|
||||||
// Filtering based on score strength
|
super.onVerticalFling(velocityY)
|
||||||
if (preferenceHelper.searchFromStart) {
|
// RecyclerView.fling expects the negated pointer velocity
|
||||||
// Filter apps that start with the search query and score higher than the filter strength
|
// (the same negation RecyclerView applies to its own
|
||||||
scoredApps.filter { (app, _) ->
|
// VelocityTracker value in onTouchEvent).
|
||||||
app.appName.startsWith(trimmedQuery, ignoreCase = true)
|
binding.drawAdapter.fling(0, -velocityY.roundToInt())
|
||||||
}
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private fun showSelectedApp(appInfo: AppInfo) {
|
||||||
binding.searchViewText.setQuery("", false)
|
binding.searchViewText.setQuery("", false)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
|||||||
import com.github.droidworksstudio.launcher.databinding.ItemDrawBinding
|
import com.github.droidworksstudio.launcher.databinding.ItemDrawBinding
|
||||||
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
||||||
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
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.Constants
|
||||||
|
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
|
||||||
|
|
||||||
class DrawViewHolder(
|
class DrawViewHolder(
|
||||||
private val binding: ItemDrawBinding,
|
private val binding: ItemDrawBinding,
|
||||||
@@ -70,7 +72,20 @@ class DrawViewHolder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
appDrawIcon.layoutParams = layoutParams
|
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
|
appDrawIcon.visibility = View.VISIBLE
|
||||||
|
|
||||||
val parentLayout = appDrawName.parent as LinearLayoutCompat
|
val parentLayout = appDrawName.parent as LinearLayoutCompat
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import android.view.ViewGroup
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
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.OnItemClickedListener
|
||||||
import com.github.droidworksstudio.launcher.listener.OnItemMoveListener
|
import com.github.droidworksstudio.launcher.listener.OnItemMoveListener
|
||||||
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
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 com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -82,6 +86,7 @@ class FavoriteFragment : Fragment(),
|
|||||||
setupRecyclerView()
|
setupRecyclerView()
|
||||||
observeFavorite()
|
observeFavorite()
|
||||||
observeHomeAppOrder()
|
observeHomeAppOrder()
|
||||||
|
observeNotificationBadges()
|
||||||
observeSwipeTouchListener()
|
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() {
|
private fun observeHomeAppOrder() {
|
||||||
binding.favoriteAdapter.adapter = favoriteAdapter
|
binding.favoriteAdapter.adapter = favoriteAdapter
|
||||||
val listener: OnItemMoveListener.OnItemActionListener = favoriteAdapter
|
val listener: OnItemMoveListener.OnItemActionListener = favoriteAdapter
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
|||||||
import com.github.droidworksstudio.launcher.databinding.ItemFavoriteBinding
|
import com.github.droidworksstudio.launcher.databinding.ItemFavoriteBinding
|
||||||
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
||||||
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
||||||
|
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
|
||||||
|
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
class FavoriteViewHolder(
|
class FavoriteViewHolder(
|
||||||
@@ -48,7 +50,18 @@ class FavoriteViewHolder(
|
|||||||
if (preferenceHelper.showAppIcon) {
|
if (preferenceHelper.showAppIcon) {
|
||||||
val appIcon =
|
val appIcon =
|
||||||
binding.root.context.packageManager.getApplicationIcon(appInfo.packageName)
|
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 =
|
appFavoriteLeftIcon.layoutParams.width =
|
||||||
preferenceHelper.appTextSize.toInt() * 3
|
preferenceHelper.appTextSize.toInt() * 3
|
||||||
appFavoriteLeftIcon.layoutParams.height =
|
appFavoriteLeftIcon.layoutParams.height =
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ import androidx.annotation.RequiresApi
|
|||||||
import androidx.appcompat.widget.AppCompatTextView
|
import androidx.appcompat.widget.AppCompatTextView
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.navigation.NavOptions
|
import androidx.navigation.NavOptions
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
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.OnItemClickedListener
|
||||||
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
||||||
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
||||||
|
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
|
||||||
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
|
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
|
||||||
import com.github.droidworksstudio.launcher.utils.Constants
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
||||||
@@ -54,6 +58,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +116,13 @@ class HomeFragment : Fragment(),
|
|||||||
setupRecyclerView()
|
setupRecyclerView()
|
||||||
observeSwipeTouchListener()
|
observeSwipeTouchListener()
|
||||||
observeUserInterfaceSettings()
|
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")
|
@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")
|
@SuppressLint("ClickableViewAccessibility", "InflateParams")
|
||||||
private fun observeSwipeTouchListener() {
|
private fun observeSwipeTouchListener() {
|
||||||
binding.apply {
|
binding.apply {
|
||||||
@@ -250,6 +286,7 @@ class HomeFragment : Fragment(),
|
|||||||
preferenceViewModel.setShowDate(preferenceHelper.showDate)
|
preferenceViewModel.setShowDate(preferenceHelper.showDate)
|
||||||
preferenceViewModel.setShowAlarmClock(preferenceHelper.showAlarmClock)
|
preferenceViewModel.setShowAlarmClock(preferenceHelper.showAlarmClock)
|
||||||
preferenceViewModel.setShowDailyWord(preferenceHelper.showDailyWord)
|
preferenceViewModel.setShowDailyWord(preferenceHelper.showDailyWord)
|
||||||
|
preferenceViewModel.setShowCurrentWeather(preferenceHelper.showCurrentWeather)
|
||||||
preferenceViewModel.setShowBattery(preferenceHelper.showBattery)
|
preferenceViewModel.setShowBattery(preferenceHelper.showBattery)
|
||||||
|
|
||||||
preferenceViewModel.showTimeLiveData.observe(viewLifecycleOwner) {
|
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 {
|
binding.apply {
|
||||||
mainView.hideKeyboard()
|
mainView.hideKeyboard()
|
||||||
|
|
||||||
@@ -321,6 +375,41 @@ 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
|
||||||
|
)
|
||||||
|
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) {
|
private fun observeBioAuthCheck(appInfo: AppInfo) {
|
||||||
if (!appInfo.lock)
|
if (!appInfo.lock)
|
||||||
context.launchApp(appInfo)
|
context.launchApp(appInfo)
|
||||||
@@ -584,6 +673,7 @@ class HomeFragment : Fragment(),
|
|||||||
binding.mainView.hideKeyboard()
|
binding.mainView.hideKeyboard()
|
||||||
observeUserInterfaceSettings()
|
observeUserInterfaceSettings()
|
||||||
observeFavoriteAppList()
|
observeFavoriteAppList()
|
||||||
|
if (preferenceHelper.showCurrentWeather) loadCurrentWeather()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAppClicked(appInfo: AppInfo) {
|
override fun onAppClicked(appInfo: AppInfo) {
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
|||||||
import com.github.droidworksstudio.launcher.databinding.ItemHomeBinding
|
import com.github.droidworksstudio.launcher.databinding.ItemHomeBinding
|
||||||
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
||||||
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
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.Constants
|
||||||
|
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class HomeViewHolder @Inject constructor(
|
class HomeViewHolder @Inject constructor(
|
||||||
@@ -70,7 +72,20 @@ class HomeViewHolder @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
appHomeIcon.layoutParams = layoutParams
|
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
|
appHomeIcon.visibility = View.VISIBLE
|
||||||
|
|
||||||
val parentLayout = appHomeName.parent as LinearLayoutCompat
|
val parentLayout = appHomeName.parent as LinearLayoutCompat
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.github.droidworksstudio.launcher.ui.settings
|
package com.github.droidworksstudio.launcher.ui.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -18,6 +21,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import com.github.droidworksstudio.common.getAppNameFromPackageName
|
import com.github.droidworksstudio.common.getAppNameFromPackageName
|
||||||
|
import com.github.droidworksstudio.common.showLongToast
|
||||||
import com.github.droidworksstudio.launcher.R
|
import com.github.droidworksstudio.launcher.R
|
||||||
import com.github.droidworksstudio.launcher.databinding.FragmentSettingsFeaturesBinding
|
import com.github.droidworksstudio.launcher.databinding.FragmentSettingsFeaturesBinding
|
||||||
import com.github.droidworksstudio.launcher.helper.AppHelper
|
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.helper.PreferenceHelper
|
||||||
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
||||||
import com.github.droidworksstudio.launcher.repository.AppInfoRepository
|
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.utils.Constants
|
||||||
import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel
|
import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
@@ -129,6 +134,8 @@ class SettingsFeaturesFragment : Fragment(),
|
|||||||
homeAlignmentBottomSwitchCompat.isChecked = preferenceHelper.homeAlignmentBottom
|
homeAlignmentBottomSwitchCompat.isChecked = preferenceHelper.homeAlignmentBottom
|
||||||
lockSettingsSwitchCompat.isChecked = preferenceHelper.settingsLock
|
lockSettingsSwitchCompat.isChecked = preferenceHelper.settingsLock
|
||||||
disableAnimationsSwitchCompat.isChecked = preferenceHelper.disableAnimations
|
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"
|
val feedbackType = if (isChecked) "on" else "off"
|
||||||
appHelper.triggerHapticFeedback(context, feedbackType)
|
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
|
private var swipeActionDialog: AlertDialog? = null
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class SettingsLookFeelFragment : Fragment(),
|
|||||||
alarmClockSwitchCompat.isChecked = preferenceHelper.showAlarmClock
|
alarmClockSwitchCompat.isChecked = preferenceHelper.showAlarmClock
|
||||||
dailyWordSwitchCompat.isChecked = preferenceHelper.showDailyWord
|
dailyWordSwitchCompat.isChecked = preferenceHelper.showDailyWord
|
||||||
appIconsSwitchCompat.isChecked = preferenceHelper.showAppIcon
|
appIconsSwitchCompat.isChecked = preferenceHelper.showAppIcon
|
||||||
|
currentWeatherSwitchCompat.isChecked = preferenceHelper.showCurrentWeather
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +189,12 @@ class SettingsLookFeelFragment : Fragment(),
|
|||||||
val feedbackType = if (isChecked) "on" else "off"
|
val feedbackType = if (isChecked) "on" else "off"
|
||||||
appHelper.triggerHapticFeedback(context, feedbackType)
|
appHelper.triggerHapticFeedback(context, feedbackType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentWeatherSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
preferenceViewModel.setShowCurrentWeather(isChecked)
|
||||||
|
val feedbackType = if (isChecked) "on" else "off"
|
||||||
|
appHelper.triggerHapticFeedback(context, feedbackType)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.github.droidworksstudio.launcher.ui.widgets
|
package com.github.droidworksstudio.launcher.ui.widgets
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.appwidget.AppWidgetHostView
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
@@ -16,8 +18,12 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.Gravity
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.appcompat.widget.AppCompatTextView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.res.ResourcesCompat
|
import androidx.core.content.res.ResourcesCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
@@ -35,7 +41,9 @@ import com.github.droidworksstudio.launcher.helper.AppHelper
|
|||||||
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
||||||
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
||||||
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
||||||
|
import com.github.droidworksstudio.launcher.service.WidgetHostManager
|
||||||
import com.github.droidworksstudio.launcher.utils.Constants
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -62,6 +70,9 @@ class WidgetFragment : Fragment(),
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var appHelper: AppHelper
|
lateinit var appHelper: AppHelper
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var widgetHostManager: WidgetHostManager
|
||||||
|
|
||||||
private lateinit var navController: NavController
|
private lateinit var navController: NavController
|
||||||
|
|
||||||
private lateinit var context: Context
|
private lateinit var context: Context
|
||||||
@@ -88,6 +99,7 @@ class WidgetFragment : Fragment(),
|
|||||||
setupBatteryWidget()
|
setupBatteryWidget()
|
||||||
observeClickListener()
|
observeClickListener()
|
||||||
observeSwipeTouchListener()
|
observeSwipeTouchListener()
|
||||||
|
setupHostedWidgets()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initializeInjectedDependencies() {
|
private fun initializeInjectedDependencies() {
|
||||||
@@ -119,6 +131,10 @@ class WidgetFragment : Fragment(),
|
|||||||
linearLayout.addView(relativeLayout)
|
linearLayout.addView(relativeLayout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hosted app widgets always live after the self-drawn widgets; this
|
||||||
|
// must be re-added here because removeAllViews() above dropped it.
|
||||||
|
linearLayout.addView(binding.widgetHostArea)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
@@ -426,6 +442,168 @@ class WidgetFragment : Fragment(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Hosted app widgets (see WidgetHostManager)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
if (preferenceHelper.showHomeWidgets) {
|
||||||
|
widgetHostManager.startListening()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
if (preferenceHelper.showHomeWidgets) {
|
||||||
|
widgetHostManager.stopListening()
|
||||||
|
}
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set up the add-widget affordance and recreate any persisted widgets. */
|
||||||
|
private fun setupHostedWidgets() {
|
||||||
|
binding.addWidgetButton.setOnClickListener { showWidgetPicker() }
|
||||||
|
restoreHostedWidgets()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-create views for every widget persisted in prefs and show the area. */
|
||||||
|
private fun restoreHostedWidgets() {
|
||||||
|
binding.widgetHostContainer.removeAllViews()
|
||||||
|
refreshWidgetAreaVisibility()
|
||||||
|
if (!preferenceHelper.showHomeWidgets) return
|
||||||
|
widgetHostManager.restoreWidgetViews().forEach { attachWidgetView(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a picker of installed widget providers. */
|
||||||
|
private fun showWidgetPicker() {
|
||||||
|
val providers = widgetHostManager.installedProviders()
|
||||||
|
if (providers.isEmpty()) {
|
||||||
|
requireContext().showLongToast(getString(R.string.widget_none_available))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val density = requireContext().resources.displayMetrics.densityDpi
|
||||||
|
val dialogContent = LinearLayout(requireContext()).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val paddingDp = (8f * resources.displayMetrics.density)
|
||||||
|
setPadding(0, paddingDp.toInt(), 0, paddingDp.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.widget_picker_title)
|
||||||
|
.setNegativeButton(R.string.settings_cancel, null)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
val scroll = android.widget.ScrollView(requireContext())
|
||||||
|
dialog.setView(scroll)
|
||||||
|
|
||||||
|
providers.forEach { provider ->
|
||||||
|
val row = AppCompatTextView(requireContext()).apply {
|
||||||
|
text = provider.loadLabel(context.packageManager)
|
||||||
|
textSize = 16f
|
||||||
|
val hPad = (12f * resources.displayMetrics.density).toInt()
|
||||||
|
val vPad = (14f * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(hPad, vPad, hPad, vPad)
|
||||||
|
isClickable = true
|
||||||
|
val icon = provider.loadIcon(requireContext(), density)
|
||||||
|
setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
|
||||||
|
compoundDrawablePadding = hPad
|
||||||
|
setOnClickListener {
|
||||||
|
dialog.dismiss()
|
||||||
|
addWidget(provider.provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialogContent.addView(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll.addView(dialogContent)
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the manager's add-widget flow and place the resulting view. */
|
||||||
|
private fun addWidget(provider: ComponentName) {
|
||||||
|
widgetHostManager.requestAddWidget(requireActivity(), provider) { result ->
|
||||||
|
when (result) {
|
||||||
|
is WidgetHostManager.AddResult.Bound -> attachWidgetView(result.view)
|
||||||
|
WidgetHostManager.AddResult.BindDenied -> requireContext().showLongToast(
|
||||||
|
getString(R.string.widget_bind_failed)
|
||||||
|
)
|
||||||
|
|
||||||
|
WidgetHostManager.AddResult.Cancelled -> {} // user backed out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a hosted widget view to the container with a small remove affordance. */
|
||||||
|
private fun attachWidgetView(view: AppWidgetHostView) {
|
||||||
|
val density = resources.displayMetrics.density
|
||||||
|
val wrapper = FrameLayout(requireContext())
|
||||||
|
wrapper.layoutParams = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
|
||||||
|
wrapper.addView(
|
||||||
|
view,
|
||||||
|
FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// A small "x" in the top-right corner, owned by us (not the remote
|
||||||
|
// widget content) so removal works even for fully interactive widgets
|
||||||
|
// like K-9's, whose own taps open the app instead of reaching a
|
||||||
|
// long-press listener.
|
||||||
|
val removeButton = AppCompatTextView(requireContext()).apply {
|
||||||
|
text = "\u2715"
|
||||||
|
textSize = 16f
|
||||||
|
setTextColor(0x6688AAAA.toInt())
|
||||||
|
val pad = (5f * density).toInt()
|
||||||
|
setPadding(pad, pad, pad, pad)
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
setOnClickListener { confirmRemoveWidget(view, wrapper) }
|
||||||
|
}
|
||||||
|
wrapper.addView(
|
||||||
|
removeButton,
|
||||||
|
FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply { gravity = Gravity.END or Gravity.TOP }
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.widgetHostContainer.addView(wrapper)
|
||||||
|
|
||||||
|
// Secondary path: long-press still removes where the widget is not
|
||||||
|
// consuming touches.
|
||||||
|
view.setOnLongClickListener {
|
||||||
|
confirmRemoveWidget(view, wrapper)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
refreshWidgetAreaVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmRemoveWidget(view: AppWidgetHostView, wrapper: ViewGroup) {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.widget_remove_title)
|
||||||
|
.setMessage(R.string.widget_remove_message)
|
||||||
|
.setPositiveButton(R.string.widget_remove) { _, _ ->
|
||||||
|
val id = view.appWidgetId
|
||||||
|
widgetHostManager.removeWidget(view, id)
|
||||||
|
binding.widgetHostContainer.removeView(wrapper)
|
||||||
|
requireContext().showLongToast(getString(R.string.widget_deleted))
|
||||||
|
refreshWidgetAreaVisibility()
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.settings_cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show/hide the hosted-widget area based on the feature toggle. */
|
||||||
|
private fun refreshWidgetAreaVisibility() {
|
||||||
|
binding.widgetHostArea.visibility =
|
||||||
|
if (preferenceHelper.showHomeWidgets) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
context.registerReceiver(batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
context.registerReceiver(batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ object Constants {
|
|||||||
const val WIDGET_BATTERY = "WIDGET_BATTERY"
|
const val WIDGET_BATTERY = "WIDGET_BATTERY"
|
||||||
|
|
||||||
const val WEATHER_PREFS = "EasyWeather.pref"
|
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 WEATHER_RESPONSE = "WEATHER_RESPONSE"
|
const val WEATHER_RESPONSE = "WEATHER_RESPONSE"
|
||||||
const val WEATHER_UNITS = "WEATHER_UNITS"
|
const val WEATHER_UNITS = "WEATHER_UNITS"
|
||||||
const val LATITUDE = "LATITUDE"
|
const val LATITUDE = "LATITUDE"
|
||||||
@@ -70,6 +74,10 @@ object Constants {
|
|||||||
const val HOME_ALLIGNMENT_BOTTOM = "HOME_ALLIGNMENT_BOTTOM"
|
const val HOME_ALLIGNMENT_BOTTOM = "HOME_ALLIGNMENT_BOTTOM"
|
||||||
const val TOGGLE_SETTING_LOCK = "TOGGLE_SETTING_LOCK"
|
const val TOGGLE_SETTING_LOCK = "TOGGLE_SETTING_LOCK"
|
||||||
const val DISABLE_ANIMATIONS = "DISABLE_ANIMATIONS"
|
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_DATE_ALIGNMENT = "HOME_DATE_ALIGNMENT"
|
||||||
const val HOME_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT"
|
const val HOME_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT"
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,4 @@ class AppViewModel @Inject constructor(
|
|||||||
appInfoRepository.updateInfo(appInfo)
|
appInfoRepository.updateInfo(appInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun searchAppInfo() = appInfoRepository.searchNote()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ class PreferenceViewModel @Inject constructor(
|
|||||||
private val autoKeyboardLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
private val autoKeyboardLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
||||||
private val lockSettingsLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
private val lockSettingsLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
||||||
private val disableAnimationsLiveData: 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 appGroupPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
|
||||||
private val appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
|
private val appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
|
||||||
|
|
||||||
@@ -297,6 +300,21 @@ class PreferenceViewModel @Inject constructor(
|
|||||||
disableAnimationsLiveData.postValue((preferenceHelper.disableAnimations))
|
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) {
|
fun setAppLanguage(appLanguage: Constants.Language) {
|
||||||
preferenceHelper.appLanguage = appLanguage
|
preferenceHelper.appLanguage = appLanguage
|
||||||
appLanguageLiveData.postValue((preferenceHelper.appLanguage))
|
appLanguageLiveData.postValue((preferenceHelper.appLanguage))
|
||||||
|
|||||||
BIN
app/src/main/res/font/jetbrains_mono_nf_weather.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_nf_weather.ttf
Normal file
Binary file not shown.
@@ -78,6 +78,14 @@
|
|||||||
android:textSize="32sp"
|
android:textSize="32sp"
|
||||||
android:visibility="gone" />
|
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>
|
||||||
|
|
||||||
<androidx.appcompat.widget.LinearLayoutCompat
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
|||||||
@@ -238,6 +238,68 @@
|
|||||||
tools:ignore="TouchTargetSizeCheck" />
|
tools:ignore="TouchTargetSizeCheck" />
|
||||||
|
|
||||||
</androidx.appcompat.widget.LinearLayoutCompat>
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
tools:ignore="MissingConstraints">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/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>
|
||||||
|
|
||||||
<androidx.appcompat.widget.LinearLayoutCompat
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
|||||||
@@ -300,6 +300,37 @@
|
|||||||
tools:ignore="TouchTargetSizeCheck" />
|
tools:ignore="TouchTargetSizeCheck" />
|
||||||
|
|
||||||
</androidx.appcompat.widget.LinearLayoutCompat>
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
tools:ignore="MissingConstraints">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/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>
|
||||||
|
|
||||||
<androidx.appcompat.widget.LinearLayoutCompat
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
|||||||
@@ -297,6 +297,36 @@
|
|||||||
android:textSize="14sp" />
|
android:textSize="14sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
android:id="@+id/widgetHostArea"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="16dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
android:id="@+id/widgetHostContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/addWidgetButton"
|
||||||
|
style="@style/TextDefaultStyle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:gravity="center"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="12dp"
|
||||||
|
android:text="@string/widget_add"
|
||||||
|
android:textColor="@color/icon_200"
|
||||||
|
android:textSize="16sp"
|
||||||
|
tools:ignore="TouchTargetSizeCheck" />
|
||||||
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
||||||
</androidx.appcompat.widget.LinearLayoutCompat>
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
||||||
</androidx.core.widget.NestedScrollView>
|
</androidx.core.widget.NestedScrollView>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
@@ -104,12 +104,24 @@
|
|||||||
<string name="settings_display_alarm_clock">Show Alarm Clock</string>
|
<string name="settings_display_alarm_clock">Show Alarm Clock</string>
|
||||||
<string name="settings_display_daily_word">Show Daily Word</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_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_automatic_keyboard">Auto Show Keyboard</string>
|
||||||
<string name="settings_display_auto_open_apps">Auto Open Last App</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_home_alignment_bottom">Home Alignment Bottom</string>
|
||||||
<string name="settings_search_from_start">Search From Start</string>
|
<string name="settings_search_from_start">Search From Start</string>
|
||||||
<string name="settings_display_lock_settings">Lock Settings</string>
|
<string name="settings_display_lock_settings">Lock Settings</string>
|
||||||
<string name="settings_display_disable_animations">Disable Animations</string>
|
<string name="settings_display_disable_animations">Disable Animations</string>
|
||||||
|
<string name="settings_display_notification_dots">Notification Dots</string>
|
||||||
|
<string name="settings_display_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_text_size_title">Size</string>
|
||||||
<string name="settings_appearance_color_title">Color</string>
|
<string name="settings_appearance_color_title">Color</string>
|
||||||
@@ -200,6 +212,7 @@
|
|||||||
<string name="accessibility_settings_disable">Disable</string>
|
<string name="accessibility_settings_disable">Disable</string>
|
||||||
|
|
||||||
<string name="accessibility_service_name">Easy Launcher Actions Service</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
|
<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>
|
accessibility service does not collect or share any data.</string>
|
||||||
|
|
||||||
|
|||||||
BIN
dist/EasyLauncher-Internet-v0.3.3-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.3-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.3-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.3-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.4-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.4-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.4-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.4-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.5-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.5-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.5-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.5-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.6-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.6-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.6-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.6-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.7-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.7-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.7-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.7-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.3-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.3-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.3-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.3-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.4-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.4-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.4-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.4-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.5-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.5-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.5-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.5-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.6-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.6-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.6-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.6-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.7-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.7-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.7-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.7-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
@@ -14,6 +14,7 @@ dependencyResolutionManagement {
|
|||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven(url = "https://jitpack.io")
|
maven(url = "https://jitpack.io")
|
||||||
|
maven(url = "https://gitea.haugesenspil.dk/api/packages/jonas/maven")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user