diff --git a/CLAUDE.md b/CLAUDE.md index fa6f85c..d31d2a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/WIDGET_HOSTING_TASK.md b/WIDGET_HOSTING_TASK.md new file mode 100644 index 0000000..76dd45b --- /dev/null +++ b/WIDGET_HOSTING_TASK.md @@ -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). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 829cf47..2a27e09 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/helper/AppHelper.kt b/app/src/main/java/com/github/droidworksstudio/launcher/helper/AppHelper.kt index 2442d3a..a0f6ede 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/helper/AppHelper.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/helper/AppHelper.kt @@ -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") } } diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/helper/weather/MetForecastResponse.kt b/app/src/main/java/com/github/droidworksstudio/launcher/helper/weather/MetForecastResponse.kt deleted file mode 100644 index b09b3ac..0000000 --- a/app/src/main/java/com/github/droidworksstudio/launcher/helper/weather/MetForecastResponse.kt +++ /dev/null @@ -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 -) - -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 = "" -) \ No newline at end of file diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/utils/MetApiService.kt b/app/src/main/java/com/github/droidworksstudio/launcher/utils/MetApiService.kt deleted file mode 100644 index 5012e63..0000000 --- a/app/src/main/java/com/github/droidworksstudio/launcher/utils/MetApiService.kt +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/dist/EasyLauncher-Internet-v0.3.7-Signed.apk b/dist/EasyLauncher-Internet-v0.3.7-Signed.apk new file mode 100644 index 0000000..e389152 Binary files /dev/null and b/dist/EasyLauncher-Internet-v0.3.7-Signed.apk differ diff --git a/dist/EasyLauncher-Internet-v0.3.7-Signed.apk.idsig b/dist/EasyLauncher-Internet-v0.3.7-Signed.apk.idsig new file mode 100644 index 0000000..3efc4b3 Binary files /dev/null and b/dist/EasyLauncher-Internet-v0.3.7-Signed.apk.idsig differ diff --git a/dist/EasyLauncher-v0.3.7-Signed.apk b/dist/EasyLauncher-v0.3.7-Signed.apk new file mode 100644 index 0000000..9f6675d Binary files /dev/null and b/dist/EasyLauncher-v0.3.7-Signed.apk differ diff --git a/dist/EasyLauncher-v0.3.7-Signed.apk.idsig b/dist/EasyLauncher-v0.3.7-Signed.apk.idsig new file mode 100644 index 0000000..81028f3 Binary files /dev/null and b/dist/EasyLauncher-v0.3.7-Signed.apk.idsig differ diff --git a/settings.gradle.kts b/settings.gradle.kts index 38fa886..1173fe5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,6 +14,7 @@ dependencyResolutionManagement { google() mavenCentral() maven(url = "https://jitpack.io") + maven(url = "https://gitea.haugesenspil.dk/api/packages/jonas/maven") } }