Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_name"/>
<!-- Where a system-rendered push lands when its channel is unknown.
A push carrying an FCM `notification` block can be drawn without
the app ever running — firebase-messaging delegates it to Google
Play services on Android 10+ unless delegation is turned off — and
that renderer resolves `android_channel_id` against the plain-key
channels PlainChannels mirrors. A push naming a channel this build
never created (an old id, a typo, one added server-side first)
otherwise goes to FCM's own "Miscellaneous" channel at default
importance with the system sound. The general announcement channel
is the honest fallback: that is what an unrecognised message is.
Must stay a real key from notification_channels.dart;
fcm_default_channel_test.dart pins it. -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="announcement-general-v2"/>
<meta-data
android:name="com.exptech.dpip.default_notification_icon"
android:resource="@drawable/ic_stat_name"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ import io.flutter.plugin.common.MethodChannel
* 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.
*
* `list` and `delete` exist for the other end of a channel's life: the ids
* earlier builds created and this one dropped, which Android keeps until the
* app deletes them. *Which* ids are retired is decided in Dart
* (`RetiredChannels`), where it is tested; this side only reports what exists
* and deletes what it is told to.
*/
class PlainChannelsChannel(private val context: Context) :
MethodChannel.MethodCallHandler {
Expand All @@ -39,13 +45,40 @@ class PlainChannelsChannel(private val context: Context) :
result.success(ensure(channels))
}

"list" -> result.success(manager().notificationChannels.map { it.id })

"delete" -> {
val ids = call.argument<List<*>>("ids")
if (ids == null || ids.any { it !is String || it.isBlank() }) {
result.error("invalid_ids", "ids must be non-empty strings", null)
return
}
result.success(delete(ids.filterIsInstance<String>()))
}

else -> result.notImplemented()
}
}

private fun manager() =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

/** Deletes each of [ids] that exists; returns how many did. */
private fun delete(ids: List<String>): Int {
val manager = manager()
var deleted = 0
for (id in ids) {
// Checked first so a repeat run reports 0 rather than a count of
// deletes that did nothing — the log line says what changed.
if (manager.getNotificationChannel(id) == null) continue
manager.deleteNotificationChannel(id)
deleted++
}
return deleted
}

private fun ensure(channels: List<Map<*, *>>): Int {
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val manager = manager()
var created = 0
for (entry in channels) {
val id = entry["id"] as? String ?: continue
Expand Down
4 changes: 4 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
AA0000000000000000000H02 /* NetworkPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000H01 /* NetworkPlugin.swift */; };
AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000D01 /* DeviceInfoPlugin.swift */; };
NL0G00000000000000000002 /* NativeLogPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = NL0G00000000000000000001 /* NativeLogPlugin.swift */; };
NS1K00000000000000000002 /* NotificationSettingsLinkPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = NS1K00000000000000000001 /* NotificationSettingsLinkPlugin.swift */; };
NL0G00000000000000000004 /* NativeLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NL0G00000000000000000003 /* NativeLogTests.swift */; };
LR0S00000000000000000002 /* LocationReportSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = LR0S00000000000000000001 /* LocationReportSender.swift */; };
LR0S00000000000000000004 /* LocationReportSenderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = LR0S00000000000000000003 /* LocationReportSenderTests.swift */; };
Expand Down Expand Up @@ -170,6 +171,7 @@
AA0000000000000000000H01 /* NetworkPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPlugin.swift; sourceTree = "<group>"; };
AA0000000000000000000D01 /* DeviceInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlugin.swift; sourceTree = "<group>"; };
NL0G00000000000000000001 /* NativeLogPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeLogPlugin.swift; sourceTree = "<group>"; };
NS1K00000000000000000001 /* NotificationSettingsLinkPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsLinkPlugin.swift; sourceTree = "<group>"; };
NL0G00000000000000000003 /* NativeLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeLogTests.swift; sourceTree = "<group>"; };
LR0S00000000000000000001 /* LocationReportSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationReportSender.swift; sourceTree = "<group>"; };
LR0S00000000000000000003 /* LocationReportSenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationReportSenderTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -454,6 +456,7 @@
AA0000000000000000000F01 /* ApnsTokenPlugin.swift */,
AA0000000000000000000D01 /* DeviceInfoPlugin.swift */,
NL0G00000000000000000001 /* NativeLogPlugin.swift */,
NS1K00000000000000000001 /* NotificationSettingsLinkPlugin.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
797A71348A49484748BB3224 /* MapSnapshotPlugin.swift */,
Expand Down Expand Up @@ -762,6 +765,7 @@
AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */,
AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */,
NL0G00000000000000000002 /* NativeLogPlugin.swift in Sources */,
NS1K00000000000000000002 /* NotificationSettingsLinkPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
071504DB0F1524DF85241961 /* MapSnapshotPlugin.swift in Sources */,
Expand Down
13 changes: 13 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ import UserNotifications
with: registry.registrar(forPlugin: "BackgroundExecutionPlugin")!)
WidgetSnapshotPlugin.register(with: registry.registrar(forPlugin: "WidgetSnapshotPlugin")!)
NativeLogPlugin.register(with: registry.registrar(forPlugin: "NativeLogPlugin")!)
NotificationSettingsLinkPlugin.register(
with: registry.registrar(forPlugin: "NotificationSettingsLinkPlugin")!)

// Re-post the launch notification the plugins just missed.
//
Expand Down Expand Up @@ -184,4 +186,15 @@ final class NotificationDelegateProxy: NSObject, UNUserNotificationCenterDelegat
wrapped.userNotificationCenter?(
center, didReceive: response, withCompletionHandler: completionHandler)
}

/// The "DPIP Notification Settings" row in iOS Settings — which exists
/// because `NotificationSettingsLinkPlugin` asked for it, so this one is
/// ours. Not forwarded: awesome (0.12.1) does not implement it, and a
/// wrapped delegate that one day did would open the page a second time.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
openSettingsFor notification: UNNotification?
) {
NotificationSettingsLinkPlugin.request()
}
}
95 changes: 95 additions & 0 deletions ios/Runner/NotificationSettingsLinkPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import Flutter
import UIKit
import UserNotifications

/// The link between iOS Settings → DPIP → Notifications and the app's own
/// notification settings page.
///
/// iOS draws a "DPIP Notification Settings" row there only for an app that
/// asked for `.providesAppNotificationSettings`, and tapping it calls the
/// notification-centre delegate's `openSettingsFor`. Both halves are here.
///
/// **Asking.** awesome_notifications builds its authorization options from a
/// fixed permission list that has no entry for this one, so the app adds it
/// itself — once the user has already said yes. Asking while the status is
/// `notDetermined` would show the system prompt outside the onboarding flow
/// that owns it, and asking without `.provisional` while provisional would
/// show the full prompt, so both are left alone. An authorized app asking
/// again is not prompted: iOS records the extra option and answers at once.
///
/// **Answering.** `NotificationDelegateProxy` receives `openSettingsFor` and
/// calls [request]. The request is a flag, not a navigation: on a cold launch
/// from Settings, iOS can call the delegate before Dart has a router, so the
/// flag waits until Dart takes it — either when nudged with `open`, or at
/// startup and on every resume. `take` reads and clears the flag in one step
/// on the main thread, so however those race, the page opens once.
public class NotificationSettingsLinkPlugin: NSObject, FlutterPlugin {
/// Main thread only, like everything else here.
private static var pending = false
private static var channel: FlutterMethodChannel?

public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "com.exptech.dpip/notification_settings_link",
binaryMessenger: registrar.messenger())
registrar.addMethodCallDelegate(NotificationSettingsLinkPlugin(), channel: channel)
NotificationSettingsLinkPlugin.channel = channel
}

/// Records that iOS asked for the app's notification settings, and nudges
/// Dart to come and take it.
static func request() {
DispatchQueue.main.async {
pending = true
// No handler yet is fine: the flag is what Dart reads at startup.
channel?.invokeMethod("open", arguments: nil)
}
}

/// Reads and clears the request flag.
static func take() -> Bool {
let was = pending
pending = false
return was
}

public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "take":
result(NotificationSettingsLinkPlugin.take())
case "provide":
NotificationSettingsLinkPlugin.provide { result($0) }
default:
result(FlutterMethodNotImplemented)
}
}

/// Asks iOS for the Settings row, if the user has already allowed
/// notifications and the row is not there yet. Completes on the main thread
/// with whether the row is now provided.
static func provide(completion: @escaping (Bool) -> Void) {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized else {
DispatchQueue.main.async { completion(false) }
return
}
if settings.providesAppNotificationSettings {
DispatchQueue.main.async { completion(true) }
return
}
// The options the app already holds, plus the one it lacks. Asking for
// fewer would not revoke anything, but naming them keeps this request
// the same shape as the one the user answered. Not `.criticalAlert`: if
// that grant is still undecided, including it would prompt for it here.
center.requestAuthorization(
options: [.alert, .sound, .badge, .providesAppNotificationSettings]
) { granted, error in
if let error {
NSLog("notification settings link request failed: \(error.localizedDescription)")
}
DispatchQueue.main.async { completion(granted && error == nil) }
}
}
}
}
2 changes: 2 additions & 0 deletions ios/Runner/Runner.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<string>development</string>
<key>com.apple.developer.usernotifications.critical-alerts</key>
<true/>
<key>com.apple.developer.usernotifications.time-sensitive</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.exptech.dpip.dpip.widgets</string>
Expand Down
9 changes: 9 additions & 0 deletions ios/Runner/RunnerRelease.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@
and provisioning profile must both carry it or signing fails outright. -->
<key>com.apple.developer.usernotifications.critical-alerts</key>
<true/>
<!-- What lets a High/Max channel through Focus and Sleep. awesome posts
those as `.timeSensitive` (NotificationBuilder.setImportance), and so
do pushes whose aps carries `interruption-level: time-sensitive` — but
without this key iOS quietly downgrades both to `.active`, and a Focus
holds the alert until the user looks. Unlike critical alerts it needs
no request to Apple: the App ID only has to have the Time Sensitive
Notifications capability switched on, or signing fails outright. -->
<key>com.apple.developer.usernotifications.time-sensitive</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.exptech.dpip.dpip.widgets</string>
Expand Down
10 changes: 10 additions & 0 deletions lib/app/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,15 @@ class _AppServicesHostState extends State<_AppServicesHost>
stagger: const Duration(milliseconds: 250),
);
NotificationTaps.drainPending();
// The iOS Settings link, including a tap that launched the app — after
// the first frame for the same reason as the drain above.
unawaited(
widget.notificationService.settingsLink.listen(
() => routeNotificationSettingsLink(
onboardingComplete: widget.onboarding.isComplete,
),
),
);
// Permissions belong to onboarding — only run the permission-dependent
// setup once it's complete (immediately for returning users).
if (widget.onboarding.isComplete) _onReady();
Expand Down Expand Up @@ -313,6 +322,7 @@ class _AppServicesHostState extends State<_AppServicesHost>
@override
void dispose() {
NotificationTaps.onTap = null;
widget.notificationService.settingsLink.stop();
widget.onboarding.removeListener(_onOnboardingChanged);
unawaited(_tokenChanges?.cancel());
widget.deviceRegistration?.dispose();
Expand Down
32 changes: 32 additions & 0 deletions lib/app/router/notification_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,35 @@ String _unmapped(String? channelKey) {
Log.warning('Notification channel not mapped to a route: $channelKey');
return AppRoutes.home;
}

/// The slice of the router the iOS Settings link needs — [GoRouter.pushNamed].
typedef NotificationSettingsNavigator = void Function(String name);

/// Opens the notification settings page for the "DPIP Notification Settings"
/// row in iOS Settings → DPIP → Notifications.
///
/// Pushed rather than gone to, so Back returns to whatever the user was doing
/// — iOS sends them here mid-session as readily as at launch. Not pushed again
/// when the page is already on top, because a second tap on the row should
/// land on the same page, not stack another copy over it. Not opened at all
/// before onboarding is finished: the router would send it straight back to
/// onboarding, which is the page the user is already on.
///
/// [push] and [currentRouteName] are injectable for tests; they default to
/// the app router.
void routeNotificationSettingsLink({
required bool onboardingComplete,
NotificationSettingsNavigator? push,
String? Function()? currentRouteName,
}) {
if (!onboardingComplete) {
Log.info('Notification settings link: onboarding not finished, ignored');
return;
}
final current = (currentRouteName ?? () => appRouter.state.name)();
if (current == AppRoutes.notifySettings) {
Log.info('Notification settings link: already on the page');
return;
}
(push ?? (name) => appRouter.pushNamed<void>(name))(AppRoutes.notifySettings);
}
11 changes: 9 additions & 2 deletions lib/core/notifications/notification_channels.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import 'package:flutter/material.dart';
/// a channel's settings after first creation, so [version] is bumped whenever a
/// definition changes to force a re-create on next launch. (Adding a brand-new
/// channel needs no bump — `initialize` creates it without touching the others.)
///
/// Renaming or dropping a channel is not finished here: Android keeps the old
/// one on every upgraded device until the app deletes it, so its key goes into
/// `RetiredChannels.keys` (`retired_channels.dart`).
abstract final class NotificationChannels {
const NotificationChannels._();

Expand Down Expand Up @@ -466,8 +470,11 @@ enum NotificationBehaviour {
///
/// That caveat is the whole difference from [overrides] and it is not
/// cosmetic. awesome maps importance to an iOS interruption level in
/// `NotificationBuilder.setImportance` — High/Max become `.timeSensitive`,
/// Default becomes `.active` — and only the `.critical` level a
/// `NotificationBuilder.setImportance` — High/Max become `.timeSensitive`
/// (which iOS honours only because the Runner entitlements carry
/// `usernotifications.time-sensitive`; without it the level silently drops
/// to `.active` and a Focus holds the alert), Default becomes `.active` —
/// and only the `.critical` level a
/// `criticalAlerts` channel gets bypasses the mute switch. The code that
/// attaches the sound is otherwise the *same branch* for both, so a channel
/// in this tier is not missing its sound on a muted phone: the sound is
Expand Down
Loading
Loading