Compare commits

3 Commits

Author SHA1 Message Date
a42f913875 refactor: use shared met-weather library for home-screen MET weather (v0.3.7)
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
Android Release CI / Build, Sign & Release (push) Has been cancelled
- Replace internal MetApiService/MetForecastResponse + Retrofit MET fetch in
  AppHelper with dk.haugesenspil:met-weather (Gitea maven).
- fetchMetWeather now delegates to MetWeatherClient; 15-min cache kept.
- Drop MetApiService.kt and MetForecastResponse.kt.
release build v0.3.7
2026-08-20 12:58:52 +02:00
1d5a568218 feat: default swipe-up gesture opens the widgets screen 2026-08-19 16:19:50 +02:00
daa1748858 feat: host app widgets on the widgets screen (K-9 unread widget) 2026-08-19 16:16:47 +02:00
21 changed files with 858 additions and 104 deletions

View File

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

214
WIDGET_HOSTING_TASK.md Normal file
View File

@@ -0,0 +1,214 @@
# Task: Add home-screen app-widget hosting to EasyLauncher
You are extending the EasyLauncher fork at `~/sources/EasyLauncher` (branch `main`).
Goal: let the user place **real Android app widgets** (in particular K-9 Mail's
"K-9 Unread" widget) on the launcher home screen, rendered live and persisted
across reboots.
Read `CLAUDE.md` in the repo root first — it is the fork's operating manual
(build, signing, on-device testing, quirks). Follow it.
## Why (background)
The launcher shows a red dot on app icons for active notifications
(`NotificationBadgeService`). K-9 Mail cancels its new-mail notification when
the app is opened, so the dot disappears even when unread mail remains. There
is no Android API to read another app's unread count. The clean, official
workaround is K-9's own **"K-9 Unread" widget**, which shows a persistent
unread count that only goes to zero when the mail is read. EasyLauncher
currently cannot host third-party app widgets (its "Widgets" page,
`WidgetFragment.kt`, is only self-drawn battery/clock widgets). This task adds
real app-widget hosting.
Scope guard: do NOT implement root-based database reading, IMAP polling, or
changes to K-9. Widget hosting only.
## Environment
- Repo: `~/sources/EasyLauncher`, branch `main`, origin = Gitea `jonas/EasyLauncher`.
No `upstream` remote. Commit locally with conventional messages (see git
history: `feat: ...`, `fix: ...`, `docs: ...`). Do not push, tag, or create
a Gitea release unless the user asks.
- Stack: Kotlin, minSdk 24, compileSdk/targetSdk 36, Java 17, Hilt (kapt),
Room, ViewBinding (+ DataBinding enabled), two product flavors
(`withInternet` / `withoutInternet`), R8 enabled in release.
- Build (offline, JDK 21):
```bash
export JAVA_HOME=~/jdk21
./gradlew :app:compileWithInternetReleaseKotlin :app:compileWithoutInternetReleaseKotlin --offline
./gradlew :app:assembleWithInternetRelease :app:assembleWithoutInternetRelease --offline
```
Outputs: `app/build/outputs/apk/{withInternet,withoutInternet}/release/`.
- Signing (for installing on the phone):
```bash
PASS=$(cat ~/android-keystores/easylauncher-release.pass)
~/android-sdk/build-tools/36.0.0/apksigner sign --v4-signing-enabled true \
--ks ~/android-keystores/easylauncher-release.jks --ks-key-alias easylauncher \
--ks-pass "pass:$PASS" --key-pass "pass:$PASS" \
--out /tmp/EasyLauncher-widgethost.apk \
app/build/outputs/apk/withInternet/release/app.easy.launcher_v0.3.6-Release.apk
```
Do NOT bump versionCode/versionName (current 36 / 0.3.6) and do NOT write
into `dist/` — this is a feature build, not a release. Installing with the
same versionCode works: `adb install -r /tmp/EasyLauncher-widgethost.apk`.
- Phone: Unihertz Titan 2 Elite, adb serial `TITAN20000043119`, rooted
(Magisk). `app.easy.launcher` (signed release) is installed and is the
default home app. K-9 Mail (`com.fsck.k9`) is installed with 3 accounts.
Device facts: after a reboot the phone is CE-locked until the user enters
the PIN — if something "doesn't work" right after a reboot, ask the user to
unlock before debugging.
- Screenshots: `adb exec-out screencap -p > /tmp/s.png`; UI dump:
`adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml`.
Logs: `adb logcat | grep -i widgethost`.
## What to build
### 1. Widget host manager (the core plumbing)
Create a Hilt `@Singleton` manager (e.g.
`app/src/main/java/com/github/droidworksstudio/launcher/service/WidgetHostManager.kt`,
or `helper/` if that fits better — follow existing package conventions) that
wraps:
- `AppWidgetHost(context, hostId)` with a unique constant hostId.
- `AppWidgetManager.getInstance(context)`.
Responsibilities:
- **`startListening()` / `stopListening()`** — delegate to `AppWidgetHost`.
- **Restore placed widgets** — re-create `AppWidgetHostView`s from persisted
widget ids after process death / reboot:
- For each stored `(appWidgetId, component)`:
- `appWidgetManager.getAppWidgetInfo(id)`; if null (unbound/removed),
drop the entry (`host.deleteAppWidget(id)` is not needed for missing
info; just clear the record).
- Else `val view = host.createView(context, id, info)` and add to the
home widget container. `createView` binds the widget; the view updates
automatically while the host is listening.
- **`requestAddWidget(component, callback)`** — the add flow:
1. `val id = host.allocateAppWidgetId()`
2. `if (!appWidgetManager.bindAppWidgetIdIfAllowed(id, component))` →
the app is not the active launcher; notify the user
("Set Easy Launcher as the default home app") and abort.
3. If the provider has a configure activity
(`providerInfo.configure != null`), launch it with
`appWidgetManager.startAppWidgetConfigureActivityForResult(activity, id,
REQUEST_ADD_WIDGET, Bundle())` (4-arg version, API 17+, fine for
minSdk 24; verify the exact signature against the local SDK — compileSdk
36 sources are available). Resume on `onActivityResult`: `RESULT_OK` →
continue; otherwise `host.deleteAppWidget(id)` and abort.
4. Create and return the bound `AppWidgetHostView`
(`host.createView(context, id, info)`).
- **`removeWidget(view, id)`** — remove view from container,
`host.deleteAppWidget(id)`, clear the persisted record.
- **Persistence** — store placed widgets in SharedPreferences (follow
`PreferenceHelper` patterns; JSON string of `id|component` pairs, key e.g.
`HOSTED_WIDGETS`). Room is acceptable but prefs match the fork's style.
- Keep it dependency-light and testable. Log with tag `WidgetHost`.
### 2. Home screen widget area
`fragment_home.xml` has a vertical `mainView` LinearLayout:
`blockView` (battery/clock/date/weather) on top, `appListTouchArea`
(RecyclerView grid) below.
- Insert a full-width widget container between `blockView` and
`appListTouchArea`, e.g. a `FrameLayout` (or vertical `LinearLayout` for
stacking) `android:id="@+id/widgetHostArea"`, `visibility="gone"` when no
widgets are placed. Keep margins consistent with the rest of the screen
(20dp horizontal, mirroring `blockView`).
- Wire in `HomeFragment.kt`:
- `onStart`: `widgetHostManager.startListening()`; restore placed widgets
into the container. `onStop`: `stopListening()`.
- Handle the add result (`onActivityResult` REQUEST_ADD_WIDGET) by inserting
the returned `AppWidgetHostView`, then persist and show the container.
- Removing: long-press a hosted widget → confirm dialog → remove.
- "Add widget" affordance when the feature is enabled: a small "+" in the
widget area when empty (and/or a settings entry — see below). Do not
break existing swipe/gesture handlers (`touchArea`, `appListTouchArea`
forwards vertical drags — the new area sits outside `appListTouchArea` so
no conflict, but verify touches on the widget still work).
- If the widget area is `gone` when empty, make sure the home grid layout
does not jump when the first widget is added.
### 3. Widget picker
A bottom sheet or `AlertDialog` listing installed widget providers:
- `appWidgetManager.getInstalledProviders()` (deprecated on API 33+ — use the
profile variant `getInstalledProvidersForProfile(UserHandle.CURRENT)` on
API 33+, guard with `Build.VERSION`). The app already has
`QUERY_ALL_PACKAGES`, so all providers are visible.
- Each row: `providerInfo.loadIcon(context, density)` + label; sort by label.
- Tapping a row → `widgetHostManager.requestAddWidget(...)` flow above.
- Opening the picker: add a settings entry (see 4). Optionally also a
long-press on empty home space if the launcher has no competing handler —
check first; if home long-press is already used, use settings only.
### 4. Settings
Follow the existing "Notification Dots" toggle pattern
(`SHOW_NOTIFICATION_DOTS` in `PreferenceHelper` +
`SettingsFeaturesFragment`):
- New pref `SHOW_HOME_WIDGETS` (default OFF).
- Toggle: "Home screen widgets".
- When ON, expose "Add widget" (opens the picker) — e.g. a secondary row or
the "+" on the home widget area.
- Add string resources in `values/strings.xml` only (the app is on Crowdin —
do not invent translations in other locales).
- Pref caveat from CLAUDE.md: don't re-push the value from
`observeUserInterfaceSettings` in a way that clobbers the on-disk value
(see the `SHOW_NOTIFICATION_DOTS` note).
### 5. Permissions
None new. Do NOT add `android.permission.BIND_APPWIDGET` (host role does not
need it). `QUERY_ALL_PACKAGES` already covers provider enumeration.
### 6. Documentation
Update `CLAUDE.md` in the repo root with a short section on the widget host
(service/manager name, pref key, how to place/remove a widget via adb if
useful, known quirks), matching the fork's existing documentation style.
## Acceptance criteria (verify on the phone)
1. Both flavors compile offline.
2. Signed `withInternet` APK installs in place over the current
`app.easy.launcher` (same signature, same versionCode 36) — `adb install -r`.
3. Settings → Features shows the new "Home screen widgets" toggle; enabling
it surfaces "Add widget".
4. The picker lists "K-9 Unread" (and other installed widgets). If K-9's
unread widget is missing, check `adb shell dumpsys package com.fsck.k9 |
grep -B2 -A6 UnreadWidgetProvider` — K-9's widget receivers are enabled
programmatically (`android:enabled` from a manifest bool); if disabled,
find the K-9 setting that enables home-screen widgets (ask the user if
unclear) or `adb shell pm enable
com.fsck.k9/com.fsck.k9.provider.UnreadWidgetProvider`.
5. Placing the K-9 Unread widget runs K-9's configuration (account picker),
then the widget renders on the home screen showing a live unread count
(currently 11 unread across the user's 3 accounts — a per-account widget
shows that account's count). Reading mail in K-9 decreases it; the dot
question is irrelevant here because the widget keeps counting until 0.
6. Widget survives: app restart (force-stop + relaunch via HOME), and a phone
reboot (after the user re-enters their PIN — CE-lock).
7. Long-press → remove works; after removal the area is hidden again and the
widget id is gone from prefs.
8. Regression: notification dots still appear (see CLAUDE.md validation
trick), home grid/weather/drawer unchanged, no crash on rotation.
9. Screenshot proof: `adb exec-out screencap -p > /tmp/widget-proof.png`
showing the K-9 Unread widget on the home screen. Keep the file and report
its path.
10. No leaked `AppWidgetHostView`s: `stopListening` on `onStop`, views
removed on removal. Watch `logcat` for host-related warnings.
## Deliverables
- Working feature per above, committed as `feat: host app widgets on home
screen (K-9 unread widget)` (or similar) — commit locally, do not push.
- `CLAUDE.md` updated.
- Verification evidence: build logs, screenshot path, list of things tested
on the phone and their outcome.
Report back concisely: what you built (files + key decisions), what you
verified on the phone, and anything the user must do (e.g. K-9 widget
setting, PIN unlock after reboot).

View File

@@ -19,8 +19,8 @@ android {
applicationId = "app.easy.launcher"
minSdk = 24
targetSdk = 36
versionCode = 36
versionName = "0.3.6"
versionCode = 37
versionName = "0.3.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders["internetPermission"] = "android.permission.INTERNET"
@@ -187,6 +187,7 @@ dependencies {
implementation(libs.material)
implementation(libs.retrofit)
implementation(libs.converter.gson)
implementation("dk.haugesenspil:met-weather:0.1.0")
implementation(libs.constraintlayout)
implementation(libs.navigation.fragment.ktx)
implementation(libs.navigation.ui.ktx)

View File

@@ -33,16 +33,15 @@ import com.github.droidworksstudio.launcher.R
import com.github.droidworksstudio.launcher.accessibility.ActionService
import com.github.droidworksstudio.launcher.data.dao.AppInfoDAO
import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.helper.weather.MetForecastResponse
import com.github.droidworksstudio.launcher.helper.weather.WeatherResponse
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.utils.MetApiService
import com.github.droidworksstudio.launcher.utils.MetSymbolMapper
import com.github.droidworksstudio.launcher.utils.WeatherApiService
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader
import dk.haugesenspil.metweather.MetWeatherClient
import kotlinx.coroutines.flow.first
import kotlin.math.roundToInt
import retrofit2.Retrofit
@@ -57,6 +56,11 @@ import javax.inject.Inject
class AppHelper @Inject constructor() {
private companion object {
/** MET requires a descriptive User-Agent identifying the app + contact. */
const val MET_USER_AGENT = "app.easy.launcher (https://gitea.haugesenspil.dk/jonas/EasyLauncher)"
}
@SuppressLint("WrongConstant", "PrivateApi")
fun expandNotificationDrawer(context: Context) {
try {
@@ -563,11 +567,12 @@ class AppHelper @Inject constructor() {
}
/**
* Fetches the current temperature and symbol from the MET/Yr API
* (api.met.no locationforecast). No API key needed - only a
* descriptive User-Agent header. Result is cached for 15 minutes.
* Fetches the current temperature and symbol from the MET/Yr API via the
* shared `met-weather` library (api.met.no locationforecast). No API key
* needed - only a descriptive User-Agent header. Result is cached for
* 15 minutes.
*/
fun fetchMetWeather(
suspend fun fetchMetWeather(
context: Context,
latitude: Float,
longitude: Float,
@@ -584,37 +589,18 @@ class AppHelper @Inject constructor() {
return cached.second
}
try {
val retrofit = Retrofit.Builder()
.baseUrl("https://api.met.no/weatherapi/locationforecast/2.0/")
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(MetApiService::class.java)
val response = service.getCompact(latitude.toDouble(), longitude.toDouble()).execute()
if (response.isSuccessful) {
val body = response.body()
val timeseries = body?.properties?.timeseries
val first = timeseries?.firstOrNull()
if (first != null) {
val temperature = first.data.instant.details.airTemperature.roundToInt()
val symbolCode = first.data.next1Hours?.summary?.symbolCode
?: first.data.next6Hours?.summary?.symbolCode
?: "cloudy"
val result = MetWeatherResult.Success(temperature, symbolCode)
context.cacheMetWeather(result)
return result
}
return MetWeatherResult.Failure("Empty forecast")
} else {
return MetWeatherResult.Failure("MET API error: ${response.code()}")
}
} catch (e: UnknownHostException) {
Log.e("AppHelper", "MET fetch: unknown host api.met.no", e)
return MetWeatherResult.Failure("Unknown host: api.met.no")
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)
return MetWeatherResult.Failure(e.message ?: "MET fetch failed")
MetWeatherResult.Failure(e.message ?: "MET fetch failed")
}
}

View File

@@ -206,6 +206,10 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
get() = prefs.getBoolean(Constants.SHOW_CURRENT_WEATHER, false)
set(value) = prefs.edit().putBoolean(Constants.SHOW_CURRENT_WEATHER, value).apply()
var showHomeWidgets: Boolean
get() = prefs.getBoolean(Constants.SHOW_HOME_WIDGETS, false)
set(value) = prefs.edit().putBoolean(Constants.SHOW_HOME_WIDGETS, value).apply()
var searchEngines: Constants.SearchEngines
get() {
return try {
@@ -252,7 +256,7 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
set(value) = prefs.edit().putString(Constants.LAUNCHER_FONT, value.name).apply()
var swipeUpAction: Constants.Action
get() = loadAction(Constants.SWIPE_UP_ACTION, Constants.Action.ShowRecents)
get() = loadAction(Constants.SWIPE_UP_ACTION, Constants.Action.ShowWidgets)
set(value) = storeAction(Constants.SWIPE_UP_ACTION, value)
var swipeDownAction: Constants.Action

View File

@@ -1,46 +0,0 @@
package com.github.droidworksstudio.launcher.helper.weather
import com.google.gson.annotations.SerializedName
/**
* Locationforecast 2.0 compact response (api.met.no) - only the fields the
* home-screen current-weather element needs.
*/
data class MetForecastResponse(
val properties: MetProperties
)
data class MetProperties(
val timeseries: List<MetTimeSeries>
)
data class MetTimeSeries(
val time: String,
val data: MetData
)
data class MetData(
val instant: MetInstant,
@SerializedName("next_1_hours")
val next1Hours: MetNextHours? = null,
@SerializedName("next_6_hours")
val next6Hours: MetNextHours? = null
)
data class MetInstant(
val details: MetDetails
)
data class MetDetails(
@SerializedName("air_temperature")
val airTemperature: Double = 0.0
)
data class MetNextHours(
val summary: MetSummary
)
data class MetSummary(
@SerializedName("symbol_code")
val symbolCode: String = ""
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +0,0 @@
package com.github.droidworksstudio.launcher.utils
import com.github.droidworksstudio.launcher.helper.weather.MetForecastResponse
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Query
interface MetApiService {
@Headers(
"User-Agent: app.easy.launcher (https://gitea.haugesenspil.dk/jonas/EasyLauncher)"
)
@GET("compact")
fun getCompact(
@Query("lat") latitude: Double,
@Query("lon") longitude: Double
): Call<MetForecastResponse>
}

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

View File

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