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