Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3da4e4d
build(storage): swap sqflite for sqlite_async
whes1015 Aug 23, 2026
94d9a77
fix(storage): recover a session whose database opens late
whes1015 Aug 23, 2026
4a734a0
fix(home): drop a rain card tick that lands after the card leaves
whes1015 Aug 23, 2026
7779993
fix(map): outline the precipitation forecast on its own grid
whes1015 Aug 23, 2026
b53394b
docs(api): record what the radar and forecast grids actually cover
whes1015 Aug 23, 2026
03d7c60
docs(api): match the endpoint table to the code that calls it
whes1015 Aug 23, 2026
174b795
perf(map): cap raster sources at their data's real zoom range
whes1015 Aug 23, 2026
73f05bc
docs(eew): record why the box grid decodes inline
whes1015 Aug 23, 2026
8270704
docs(eew): record why the historical RTS decode stays inline
whes1015 Aug 23, 2026
ccbc0f0
feat(rain): read the ramp against CWA bands with two threshold tables
whes1015 Aug 24, 2026
635e9bb
docs(weather): correct the QPESUMS grid extent to cell edges
whes1015 Aug 24, 2026
dd9ba9b
build(android): keep Hybrid Composition++ off for the map view
whes1015 Aug 24, 2026
1bc4c63
refactor(storage): let the cache schema drop a legacy table itself
whes1015 Aug 24, 2026
0af86c4
refactor(settings): harden attach reconciliation against races
whes1015 Aug 24, 2026
ca785bc
perf(map): stop the timeline warm from thrashing its own caches
whes1015 Aug 24, 2026
1130311
fix(map): answer the OS memory-pressure call before lmkd picks us
whes1015 Aug 24, 2026
81472eb
fix(notify): accept the legacy nested payload the backend still sends
whes1015 Aug 24, 2026
b1add83
fix(notify): re-create channels so updated devices hear their own sound
whes1015 Aug 24, 2026
355b8b6
fix(notify): clamp oversized push ids instead of dropping the alert
whes1015 Aug 24, 2026
f4d38c5
fix(notify): rebuild channels on plain keys when the catalogue changes
whes1015 Aug 24, 2026
6008943
fix(notify): stop marking every push as an insistent alarm
whes1015 Aug 24, 2026
0182bd7
fix(notify): mirror plain-key channels for FCM background renders
whes1015 Aug 24, 2026
444cb24
feat(wind): advect the particles inside the map's own GL layer
whes1015 Aug 24, 2026
9af780e
build(android): enable hybrid composition for the map platform view
whes1015 Aug 24, 2026
53368b9
refactor(changelog): name install sources by their update channel
PiscesXD Aug 24, 2026
956d440
fix(ios): stop mistaking dev and re-signed installs for store ones
PiscesXD Aug 24, 2026
851f674
refactor(android): tell development installs apart from github ones
PiscesXD Aug 24, 2026
4ffeba7
chore(diagnostics): load the install source during bootstrap
PiscesXD Aug 24, 2026
2c0bb72
feat(location): add search to the region picker
PiscesXD Aug 24, 2026
c302381
feat(map): let layers be hidden from the picker
PiscesXD Aug 24, 2026
eeeeb7a
perf(map): draw wind forecast particles natively on iOS
PiscesXD Aug 24, 2026
9484f9d
build(deps): bump the maplibre fork for wind-layer position fixes
whes1015 Aug 24, 2026
f21d05e
fix(notify): present startup-window pushes iOS declined to show
whes1015 Aug 24, 2026
daf87fa
fix(notify): bundle the Firebase config iOS builds were missing
whes1015 Aug 24, 2026
b2eb063
refactor(notify): hand remote push to awesome_notifications_fcm
whes1015 Aug 24, 2026
96dc057
fix(notify): show foreground pushes iOS was leaving silent
whes1015 Aug 24, 2026
75765c5
refactor(notify): give the push token one writer instead of four
whes1015 Aug 24, 2026
f3db632
fix(weather): stop mirroring the rain backdrop on GLES devices
whes1015 Aug 24, 2026
e5f67ff
refactor(notify): read the APNs token from iOS, not from Firebase
whes1015 Aug 24, 2026
34f6767
build(deps): resolve the lockfile against the current dependencies
whes1015 Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,51 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config"
android:allowBackup="false">

<!-- Hybrid Composition++ — the map is a platform view, and the default
strategy is Virtual Display, which the engine itself logs as
"legacy" and which Flutter's own docs say "can cause jank during
high-frequency updates". Zooming is exactly that. HCPP hands
compositing to the OS through Vulkan and SurfaceControl instead.
Requires API 34+, Vulkan and Impeller; below that the engine falls
back on its own, so this is safe to ship. Remove the line to revert. -->
<!-- On deliberately, with one known cost. HCPP composites the map as
its own SurfaceControl layer, so map pixels never pass through
Flutter's rasterizer. It is the only Android path that zooms
smoothly: Virtual Display is locked to 60 Hz by AOSP on this
120 Hz panel, and Flutter's own position on the texture-layer path
is "There are no plans to improve TLHC performance currently"
(flutter/flutter#183626). Both were measured here in a profile
build and both janked.

THE COST, measured on a Pixel 9 (Android 17, Vulkan/Impeller):
Flutter 3.47.x leaks one full-screen HardwareBuffer (10.47 MB) per
frame that any Flutter content presents ABOVE the map.
impeller/.../ahb/ahb_texture_pool_vk.cc Pop() allocates when the
pool is empty and Push() has no cap and no GC — the header still
claims both; PR #166943 deleted them. Push()'s only caller is a
SurfaceControl transaction on-complete callback that the engine
deliberately does not apply under HCPP. The file is byte-identical
(sha256 1a7016c1…) at 3.47.0, 3.47.1, flutter-3.48-candidate.0 and
master, so no upgrade available today fixes it.

In practice: the wind layer's animated particle overlay drove GL
memory 394 -> 8042 MB in 16 s and lmkd killed the process at
8.6 GB. Every other layer is stable (radar holds ~1 GB for minutes)
because nothing repaints continuously above the map. The wind
particles are being moved into a MapLibre layer to remove the last
continuous overlay; until that lands, wind is the one screen this
flag is not safe on.

Also unfixed on 3.47.x: PlatformViewsController2.onEndFrame() can
abort the process on teardown (flutter/flutter#190609). The guard
is on master only — verify by reading the tag's source, never by
version number. Detect with
adb logcat -b crash | grep 'Check failed: fml::jni::CheckException'
— Crashlytics cannot see a SIGABRT. -->
<meta-data
android:name="io.flutter.embedding.android.EnableHcpp"
android:value="true" />
<!-- Monochrome status-bar icon for FCM/awesome notifications. -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
Expand Down
15 changes: 11 additions & 4 deletions android/app/src/main/kotlin/com/exptech/dpip/DeviceInfoChannel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.exptech.dpip
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Build
import android.provider.Settings
import io.flutter.plugin.common.MethodCall
Expand Down Expand Up @@ -39,10 +40,16 @@ class DeviceInfoChannel(private val context: Context) :

/**
* Where this build came from — which decides where an update prompt sends
* the user. A sideloaded APK has no store page to update from, so it is
* reported as such and gets the GitHub release instead.
* the user. A debuggable build (flutter run, Android Studio, adb) is a
* development environment and says so: the flag is part of the build, not
* an installer record, so it holds even though adb leaves no installer. A
* non-store release has no store page to update from and gets the GitHub
* release instead.
*/
private fun installSource(): String {
if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
return "development"
}
val installer = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.packageManager
Expand All @@ -54,10 +61,10 @@ class DeviceInfoChannel(private val context: Context) :
}
} catch (e: Exception) {
// The package can be queried out from under us (uninstalling while
// running); an unknown installer is a sideload for our purposes.
// running); an unknown installer means no store did it.
null
}
return if (installer == "com.android.vending") "playStore" else "sideload"
return if (installer == "com.android.vending") "playStore" else "github"
}

/** Total physical RAM in MiB — the cheap proxy for the low-end tier. */
Expand Down
3 changes: 3 additions & 0 deletions android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,8 @@ class MainActivity : FlutterActivity() {

EventChannel(messenger, CompassChannel.NAME)
.setStreamHandler(CompassChannel(applicationContext))

MethodChannel(messenger, PlainChannelsChannel.NAME)
.setMethodCallHandler(PlainChannelsChannel(applicationContext))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.exptech.dpip

import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.AudioAttributes
import android.net.Uri
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel

/**
* Mirrors the notification catalogue under **plain, un-hashed channel IDs**.
*
* awesome_notifications derives each Android channel's ID from a hash of its
* model, so the ID a locally-rendered notification targets is not stable
* across builds. FCM's system-tray path cannot follow that dance at all: a
* push carrying an FCM `notification` block is rendered by the SDK itself,
* which looks up `android_channel_id` — the plain key from the backend —
* verbatim, and falls back to the system default channel (system sound) the
* moment it misses.
*
* [ensure] creates any missing plain-key channel straight through
* NotificationManager. An existing channel is never touched — user tuning
* survives ordinary launches, and the Dart-side catalogue-version gate is
* what drives delete + re-create when the sound files themselves change.
*/
class PlainChannelsChannel(private val context: Context) :
MethodChannel.MethodCallHandler {

companion object {
const val NAME = "com.exptech.dpip/plain_notification_channels"
}

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"ensure" -> {
val channels =
call.argument<List<Map<*, *>>>("channels") ?: emptyList()
result.success(ensure(channels))
}

else -> result.notImplemented()
}
}

private fun ensure(channels: List<Map<*, *>>): Int {
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
var created = 0
for (entry in channels) {
val id = entry["id"] as? String ?: continue
// Create-if-missing only: a channel that already exists may carry
// settings the user tuned in the OS UI, and rewriting it would be
// ignored for behaviour anyway — Android freezes created channels.
if (manager.getNotificationChannel(id) != null) continue

val name = entry["name"] as? String ?: continue
val importance = (entry["importance"] as? Number)?.toInt()
?: NotificationManager.IMPORTANCE_DEFAULT

val channel = NotificationChannel(id, name, importance)
(entry["description"] as? String)?.let { channel.description = it }
(entry["group"] as? String)?.let { channel.group = it }

// Read from the current APK resources, so the URI is correct by
// construction — no stale numeric resource ids, ever.
val sound = entry["sound"] as? String
if (sound != null) {
val resId =
context.resources.getIdentifier(sound, "raw", context.packageName)
if (resId > 0) {
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
channel.setSound(
Uri.parse("android.resource://${context.packageName}/$resId"),
attributes,
)
}
}

(entry["vibrationPattern"] as? List<*>)?.let { pattern ->
val longs = pattern.mapNotNull { (it as? Number)?.toLong() }
.toLongArray()
if (longs.isNotEmpty()) channel.vibrationPattern = longs
}
(entry["ledColor"] as? Number)?.let { color ->
channel.enableLights(true)
channel.lightColor = color.toInt()
}

manager.createNotificationChannel(channel)
created++
}
return created
}
}
Loading
Loading