diff --git a/android/app/src/main/java/me/masonasons/fastsm/ui/CoreViewModel.kt b/android/app/src/main/java/me/masonasons/fastsm/ui/CoreViewModel.kt index 907b382..b313d90 100644 --- a/android/app/src/main/java/me/masonasons/fastsm/ui/CoreViewModel.kt +++ b/android/app/src/main/java/me/masonasons/fastsm/ui/CoreViewModel.kt @@ -318,6 +318,8 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) { // persisted value and the device-side plumbing; this mirrors it for the UI. private val _pushEnabled = MutableStateFlow(false) val pushEnabled: StateFlow = _pushEnabled.asStateFlow() + private val _pushAlertTypes = MutableStateFlow>>(emptyList()) + val pushAlertTypes = _pushAlertTypes.asStateFlow() // The hashtags in a post, to pick which one's timeline to open (the core // sends this only when a post has several; one tag opens directly). @@ -357,6 +359,13 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) { "settings" -> { val settings = e.optJSONObject("settings") _settings.value = settings + val types = e.optJSONArray("push_alert_types") + _pushAlertTypes.value = buildList { + if (types != null) for (i in 0 until types.length()) { + val type = types.getJSONObject(i) + add(type.getString("key") to type.getString("label")) + } + } val sp = e.optJSONArray("soundpacks") _soundpacks.value = buildList { if (sp != null) for (i in 0 until sp.length()) add(sp.optString(i)) @@ -603,6 +612,9 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) { "push_unsubscribe_result" -> { pushToggleInFlight = false } + "push_update_alerts_result" -> { + // Keep saved choices and the master switch. The core announces failures. + } "hashtag_timeline_picker" -> { val arr = e.optJSONArray("tags") @@ -866,6 +878,7 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) { return } PushManager.enable(app) { sub -> + if (!PushManager.isEnabled(app)) return@enable // Send whatever we got, including nothing: no Firebase token or a // relay that wouldn't answer means an empty endpoint, and the core // turns that into the same spoken failure as any other, so no @@ -886,7 +899,7 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) { fun refreshPush() { syncPushEnabled() PushManager.refreshIfEnabled(getApplication()) { sub -> - if (sub == null) return@refreshIfEnabled + if (sub == null || !PushManager.isEnabled(getApplication())) return@refreshIfEnabled core.dispatch("push_subscribe") { put("endpoint", sub.endpoint) put("p256dh", sub.p256dh) @@ -895,6 +908,14 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) { } } + /** Save one push type without replacing other pending setting changes. */ + fun setPushAlert(key: String, enabled: Boolean) { + core.dispatch("push_update_alerts") { + put("alerts", JSONObject().put(key, enabled)) + put("apply_to_subscriptions", PushManager.isEnabled(getApplication())) + } + } + /** Close a tab: select it (so it's current), then dismiss it. */ fun closeTimeline(index: Int) { selectTimeline(index) diff --git a/android/app/src/main/java/me/masonasons/fastsm/ui/settings/SettingsScreen.kt b/android/app/src/main/java/me/masonasons/fastsm/ui/settings/SettingsScreen.kt index 7d21010..ab2eb73 100644 --- a/android/app/src/main/java/me/masonasons/fastsm/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/me/masonasons/fastsm/ui/settings/SettingsScreen.kt @@ -190,7 +190,7 @@ fun SettingsScreen(viewModel: CoreViewModel, onClose: () -> Unit) { when { speechList != null -> SpeechFieldEditor(s, speechList!!, viewModel) panel == "general" -> GeneralPanel(s, viewModel) - panel == "notifications" -> NotificationsPanel(viewModel) + panel == "notifications" -> NotificationsPanel(s, viewModel) panel == "timelines" -> TimelinesPanel(s, viewModel) panel == "audio" -> AudioPanel(s, soundpacks, viewModel) panel == "earcons" -> EarconsPanel(s, viewModel) @@ -247,8 +247,9 @@ private fun GeneralPanel(s: JSONObject, vm: CoreViewModel) { * a core setting, so it doesn't come through the settings JSON like the others. */ @Composable -private fun NotificationsPanel(vm: CoreViewModel) { +private fun NotificationsPanel(s: JSONObject, vm: CoreViewModel) { val enabled by vm.pushEnabled.collectAsStateWithLifecycle() + val types by vm.pushAlertTypes.collectAsStateWithLifecycle() val context = LocalContext.current // The stored preference is the truth; pick it up when the panel opens. @@ -278,8 +279,14 @@ private fun NotificationsPanel(vm: CoreViewModel) { } HelpText( "Get notified of mentions, boosts, favorites, follows and more while FastSMRW " + - "is closed. Mastodon accounts only." + "is closed. These choices apply to all Mastodon accounts on this device." ) + val alerts = s.optJSONObject("push_alerts") + types.forEach { (key, label) -> + SwitchRow(label, alerts?.optBoolean(key, true) ?: true) { on -> + vm.setPushAlert(key, on) + } + } } @Composable diff --git a/apple/shared/AppState.swift b/apple/shared/AppState.swift index 4d053da..78f68b9 100644 --- a/apple/shared/AppState.swift +++ b/apple/shared/AppState.swift @@ -29,6 +29,7 @@ final class AppState { /// (with edits) on update_settings — the core re-applies defaults for any /// missing key, so we must always send the whole object. private(set) var settingsRaw: [String: Any] = [:] + private(set) var pushAlertTypes: [(key: String, label: String)] = [] private(set) var soundpacks: [String] = [] /// Output devices the core's mixer can play sound effects through (desktop /// settings; iOS routes audio itself and ignores this). @@ -132,6 +133,12 @@ final class AppState { let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], obj["event"] as? String == "settings" { if let s = obj["settings"] as? [String: Any] { settingsRaw = s } + if let types = obj["push_alert_types"] as? [[String: String]] { + pushAlertTypes = types.compactMap { item in + guard let key = item["key"], let label = item["label"] else { return nil } + return (key: key, label: label) + } + } if let packs = obj["soundpacks"] as? [String] { soundpacks = packs } if let devices = obj["sound_devices"] as? [String] { soundDevices = devices } onSettings?() @@ -235,6 +242,9 @@ final class AppState { onPushSubscribeResult?(e.ok) case let .pushUnsubscribeResult(e): onPushUnsubscribeResult?(e.ok) + case .pushUpdateAlertsResult: + // Saved choices remain selected on failure; the core announces details. + break case let .followedHashtags(e): onFollowedHashtags?(e) case let .trendingHashtags(e): @@ -418,6 +428,14 @@ final class AppState { client.send("push_unsubscribe", ["announce": announce]) } + func setPushAlert(_ key: String, enabled: Bool, applyToSubscriptions: Bool) { + var alerts = settingsRaw["push_alerts"] as? [String: Bool] ?? [:] + alerts[key] = enabled + settingsRaw["push_alerts"] = alerts + client.send("push_update_alerts", ["alerts": [key: enabled], + "apply_to_subscriptions": applyToSubscriptions]) + } + // Followed hashtags (Mastodon) func followHashtagPrompt(id: String) { client.send("follow_hashtag_prompt", ["id": id]) } func followHashtag(name: String) { client.send("follow_hashtag", ["name": name]) } diff --git a/apple/shared/CoreEvents.swift b/apple/shared/CoreEvents.swift index 14adff1..b82c69a 100644 --- a/apple/shared/CoreEvents.swift +++ b/apple/shared/CoreEvents.swift @@ -594,6 +594,7 @@ enum CoreEvent { case hashtagTimelinePicker(HashtagTimelinePicker) case pushSubscribeResult(PushResult) case pushUnsubscribeResult(PushResult) + case pushUpdateAlertsResult(PushResult) case followedHashtags(FollowedHashtags) case trendingHashtags(FollowedHashtags) case aliasPrompt(AliasPrompt) @@ -646,6 +647,8 @@ enum CoreEvent { return decode(PushResult.self).map(CoreEvent.pushSubscribeResult) case "push_unsubscribe_result": return decode(PushResult.self).map(CoreEvent.pushUnsubscribeResult) + case "push_update_alerts_result": + return decode(PushResult.self).map(CoreEvent.pushUpdateAlertsResult) case "followed_hashtags": return decode(FollowedHashtags.self).map(CoreEvent.followedHashtags) case "trending_hashtags": return decode(FollowedHashtags.self).map(CoreEvent.trendingHashtags) case "alias_prompt": return decode(AliasPrompt.self).map(CoreEvent.aliasPrompt) diff --git a/build.bat b/build.bat index 1432b0c..1783064 100644 --- a/build.bat +++ b/build.bat @@ -202,7 +202,7 @@ if defined ISCC ( REM ---- 3) optional: tests ---- if "%RUN_TESTS%"=="1" ( echo Compiling tests... - cl %CFLAGS% %COREINC% /I tests tests\main.cpp tests\test_models.cpp tests\test_util.cpp tests\test_mastodon_map.cpp tests\test_bluesky_map.cpp tests\test_bluesky_richtext.cpp tests\test_auth.cpp tests\test_store.cpp tests\test_presentation.cpp tests\test_speech.cpp tests\test_sse.cpp tests\test_capi.cpp tests\test_thread.cpp tests\test_keymap.cpp tests\test_update.cpp tests\test_filters.cpp tests\test_timeline_refresh.cpp "%BUILD%\fastsm_core.lib" /Fo"%OBJ%\test\\" /Fe"%BUILD%\fastsm_tests.exe" /link %LINKFLAGS% crypt32.lib + cl %CFLAGS% %COREINC% /I tests tests\main.cpp tests\test_models.cpp tests\test_util.cpp tests\test_mastodon_map.cpp tests\test_bluesky_map.cpp tests\test_bluesky_richtext.cpp tests\test_auth.cpp tests\test_store.cpp tests\test_presentation.cpp tests\test_speech.cpp tests\test_sse.cpp tests\test_capi.cpp tests\test_push.cpp tests\test_thread.cpp tests\test_keymap.cpp tests\test_update.cpp tests\test_filters.cpp tests\test_timeline_refresh.cpp "%BUILD%\fastsm_core.lib" /Fo"%OBJ%\test\\" /Fe"%BUILD%\fastsm_tests.exe" /link %LINKFLAGS% crypt32.lib if errorlevel 1 goto error echo Running tests... "%BUILD%\fastsm_tests.exe" diff --git a/core/include/fastsm/models/push_alerts.hpp b/core/include/fastsm/models/push_alerts.hpp new file mode 100644 index 0000000..552ac14 --- /dev/null +++ b/core/include/fastsm/models/push_alerts.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include + +namespace fastsm { + +// Device-wide preferences shared by every push-capable account. Defaults keep +// the alert types enabled by older versions of the mobile clients. +struct PushAlerts { + bool mention = true; + bool favourite = true; + bool reblog = true; + bool follow = true; + bool follow_request = true; + bool poll = true; + bool status = true; + bool update = true; +}; + +struct PushAlertDef { + const char* key; + const char* label; + bool PushAlerts::* enabled; +}; + +inline constexpr std::array push_alert_catalog = {{ + {"mention", "Mentions", &PushAlerts::mention}, + {"favourite", "Favorites", &PushAlerts::favourite}, + {"reblog", "Boosts", &PushAlerts::reblog}, + {"follow", "Follows", &PushAlerts::follow}, + {"follow_request", "Follow requests", &PushAlerts::follow_request}, + {"poll", "Poll results", &PushAlerts::poll}, + {"status", "Posts from accounts you've enabled notifications for", &PushAlerts::status}, + {"update", "Post edits", &PushAlerts::update}, +}}; + +} // namespace fastsm diff --git a/core/include/fastsm/platform/mastodon/mastodon_account.hpp b/core/include/fastsm/platform/mastodon/mastodon_account.hpp index 60a206f..b74e520 100644 --- a/core/include/fastsm/platform/mastodon/mastodon_account.hpp +++ b/core/include/fastsm/platform/mastodon/mastodon_account.hpp @@ -73,7 +73,8 @@ class MastodonAccount : public SocialAccount { std::vector trending_hashtags() override; PushSubscribe subscribe_push(const std::string& endpoint, const std::string& p256dh, - const std::string& auth) override; + const std::string& auth, const PushAlerts& alerts) override; + PushSubscribe update_push_alerts(const PushAlerts& alerts) override; bool unsubscribe_push() override; std::vector lists() override; diff --git a/core/include/fastsm/platform/social_account.hpp b/core/include/fastsm/platform/social_account.hpp index 6393574..fe9d33d 100644 --- a/core/include/fastsm/platform/social_account.hpp +++ b/core/include/fastsm/platform/social_account.hpp @@ -1,5 +1,7 @@ #pragma once +#include "fastsm/models/push_alerts.hpp" + #include #include #include @@ -331,11 +333,15 @@ class SocialAccount { // base64url. Runs synchronously on the worker thread. virtual PushSubscribe subscribe_push(const std::string& /*endpoint*/, const std::string& /*p256dh*/, - const std::string& /*auth*/) { + const std::string& /*auth*/, const PushAlerts& /*alerts*/) { return PushSubscribe::Failed; } // Remove this account's push subscription. Return success. virtual bool unsubscribe_push() { return false; } + // Update only alert types, preserving the subscription endpoint and keys. + virtual PushSubscribe update_push_alerts(const PushAlerts& /*alerts*/) { + return PushSubscribe::Failed; + } // --- Server-side keyword filters (optional; Mastodon /api/v2/filters) --- // Whether this platform exposes managed server filters at all (Mastodon yes, diff --git a/core/include/fastsm/session/core_session.hpp b/core/include/fastsm/session/core_session.hpp index 47d3c9b..4782a10 100644 --- a/core/include/fastsm/session/core_session.hpp +++ b/core/include/fastsm/session/core_session.hpp @@ -125,6 +125,9 @@ class CoreSession { // the setting, so a quiet renewal at startup says nothing. void cmd_push_subscribe(const nlohmann::json& cmd); void cmd_push_unsubscribe(const nlohmann::json& cmd); // {announce?} + // {alerts:{type:bool,...}, apply_to_subscriptions?:false, announce?:true} + // Saves a patch; updates existing subscriptions only when the device enables push. + void cmd_push_update_alerts(const nlohmann::json& cmd); // Emit a push result event and, when announce is set, speak the outcome. void emit_push_result(const char* event, bool ok, const std::string& reason, bool announce); // {id} -> spawns the post's only hashtag timeline, or emits a diff --git a/core/include/fastsm/store/app_settings.hpp b/core/include/fastsm/store/app_settings.hpp index 6833b37..767c8bd 100644 --- a/core/include/fastsm/store/app_settings.hpp +++ b/core/include/fastsm/store/app_settings.hpp @@ -4,12 +4,14 @@ #include #include "fastsm/presentation/speech_settings.hpp" +#include "fastsm/models/push_alerts.hpp" namespace fastsm::store { // User preferences. Grows over time (Mac has many more); for now the pieces M1 // needs plus the configurable speech field order/visibility. struct AppSettings { + PushAlerts push_alerts; bool sounds_enabled = true; int sound_volume = 100; // master earcon/soundpack volume, 0-100 percent int media_volume = 100; // audio-attachment playback volume, 0-100 percent diff --git a/core/src/platform/mastodon/mastodon_account.cpp b/core/src/platform/mastodon/mastodon_account.cpp index 612ccce..ce20551 100644 --- a/core/src/platform/mastodon/mastodon_account.cpp +++ b/core/src/platform/mastodon/mastodon_account.cpp @@ -1264,22 +1264,18 @@ bool MastodonAccount::unfollow_hashtag(const std::string& name) { PushSubscribe MastodonAccount::subscribe_push(const std::string& endpoint, const std::string& p256dh, - const std::string& auth) { + const std::string& auth, const PushAlerts& alerts) { // POST /api/v1/push/subscription. Replaces any existing subscription for - // this access token. Subscribe to every alert type; the UI can refine later. - const std::vector> params = { + // this access token. Always apply the saved device-wide alert preferences. + std::vector> params = { {"subscription[endpoint]", endpoint}, {"subscription[keys][p256dh]", p256dh}, {"subscription[keys][auth]", auth}, - {"data[alerts][mention]", "true"}, - {"data[alerts][favourite]", "true"}, - {"data[alerts][reblog]", "true"}, - {"data[alerts][follow]", "true"}, - {"data[alerts][follow_request]", "true"}, - {"data[alerts][poll]", "true"}, - {"data[alerts][status]", "true"}, - {"data[alerts][update]", "true"}, }; + for (const auto& def : push_alert_catalog) { + params.emplace_back(std::string("data[alerts][") + def.key + "]", + alerts.*(def.enabled) ? "true" : "false"); + } const std::string url = credentials_.instance_url + "/api/v1/push/subscription"; std::string body; long status = 0; @@ -1301,6 +1297,21 @@ bool MastodonAccount::unsubscribe_push() { return request("DELETE", url, "", "", body, status); } +PushSubscribe MastodonAccount::update_push_alerts(const PushAlerts& alerts) { + std::vector> params; + for (const auto& def : push_alert_catalog) { + params.emplace_back(std::string("data[alerts][") + def.key + "]", + alerts.*(def.enabled) ? "true" : "false"); + } + std::string body; + long status = 0; + if (request("PUT", credentials_.instance_url + "/api/v1/push/subscription", + util::form_encode(params), "application/x-www-form-urlencoded", body, status)) { + return PushSubscribe::Ok; + } + return status == 403 ? PushSubscribe::NeedsReauth : PushSubscribe::Failed; +} + std::vector MastodonAccount::followed_hashtags() { // GET /api/v1/followed_tags. One page (up to 200) is plenty for a manager UI. const std::string url = credentials_.instance_url + "/api/v1/followed_tags?limit=200"; diff --git a/core/src/session/core_session.cpp b/core/src/session/core_session.cpp index a3e5f94..4b09068 100644 --- a/core/src/session/core_session.cpp +++ b/core/src/session/core_session.cpp @@ -578,6 +578,8 @@ void CoreSession::handle(const json& cmd) { cmd_push_subscribe(cmd); else if (c == "push_unsubscribe") cmd_push_unsubscribe(cmd); + else if (c == "push_update_alerts") + cmd_push_update_alerts(cmd); else if (c == "list_followed_hashtags") cmd_list_followed_hashtags(); else if (c == "list_trending_hashtags") @@ -1626,11 +1628,12 @@ void CoreSession::cmd_push_subscribe(const json& cmd) { emit_push_result("push_subscribe_result", false, "unsupported", announce); return; } - worker_.post([this, targets, endpoint, p256dh, auth, announce] { + const PushAlerts alerts = settings_.push_alerts; + worker_.post([this, targets, endpoint, p256dh, auth, announce, alerts] { bool any_ok = false; bool any_reauth = false; for (SocialAccount* a : targets) { - switch (a->subscribe_push(endpoint, p256dh, auth)) { + switch (a->subscribe_push(endpoint, p256dh, auth, alerts)) { case PushSubscribe::Ok: any_ok = true; break; @@ -1670,6 +1673,75 @@ void CoreSession::cmd_push_unsubscribe(const json& cmd) { }); } +void CoreSession::cmd_push_update_alerts(const json& cmd) { + // Patch on the core loop: two quick toggles cannot overwrite one another + // with an older settings snapshot from a mobile UI. + if (auto it = cmd.find("alerts"); it != cmd.end() && it->is_object()) { + for (const auto& def : push_alert_catalog) { + auto value = it->find(def.key); + if (value != it->end() && value->is_boolean()) { + settings_.push_alerts.*(def.enabled) = value->get(); + } + } + } + save_config(); + emit_settings(); + if (!cmd.value("apply_to_subscriptions", false)) { + emit({{"event", "push_update_alerts_result"}, {"ok", true}, + {"accounts", json::array()}}); + return; + } + const bool announce = cmd.value("announce", true); + const PushAlerts alerts = settings_.push_alerts; + // Own account snapshots so removing an account during an update cannot + // invalidate objects still in use by the worker. Push currently supports Mastodon. + std::vector> targets; + for (SocialAccount* a : accounts_.accounts()) { + if (a && a->platform() == Platform::Mastodon && a->features().web_push) { + const auto* mastodon = static_cast(a); + targets.push_back(std::make_shared( + mastodon->credentials(), mastodon->me(), http_.get())); + } + } + if (targets.empty()) { + emit({{"event", "push_update_alerts_result"}, {"ok", false}, + {"reason", "unsupported"}, {"accounts", json::array()}}); + if (announce) { + emit_announce("Your choices are saved. Push notifications need a Mastodon account."); + } + return; + } + worker_.post([this, targets, alerts, announce] { + json results = json::array(); + std::string failures; + for (const auto& a : targets) { + const auto result = a->update_push_alerts(alerts); + const bool ok = result == PushSubscribe::Ok; + const std::string reason = ok ? "" : + (result == PushSubscribe::NeedsReauth ? "reauth" : "failed"); + results.push_back({{"account_key", a->account_key()}, {"ok", ok}, {"reason", reason}}); + if (!ok) { + if (!failures.empty()) { + failures += " "; + } + failures += "Couldn't update push notification types for " + a->me().acct + "."; + if (result == PushSubscribe::NeedsReauth) { + failures += " Remove that account and add it again to allow push notifications."; + } + } + } + loop_.post([this, results = std::move(results), failures, announce] { + emit({{"event", "push_update_alerts_result"}, {"ok", failures.empty()}, + {"accounts", results}}); + if (announce && !failures.empty()) { + sound_.play(sound::Earcon::Error); + emit_announce("Your choices are saved and will be retried when push notifications " + "are renewed. " + failures); + } + }); + }); +} + void CoreSession::cmd_list_followed_hashtags() { emit_followed_hashtags(); } void CoreSession::cmd_list_trending_hashtags() { @@ -4912,6 +4984,10 @@ void CoreSession::emit(const json& event) { } void CoreSession::emit_settings(bool refresh_devices) { + json push_types = json::array(); + for (const auto& def : push_alert_catalog) { + push_types.push_back({{"key", def.key}, {"label", def.label}}); + } json packs = json::array(); for (const auto& p : sound_.list_soundpacks()) packs.push_back(p); @@ -4924,6 +5000,7 @@ void CoreSession::emit_settings(bool refresh_devices) { for (const auto& d : sound_devices_) devices.push_back(d); emit({{"event", "settings"}, + {"push_alert_types", std::move(push_types)}, {"settings", store::settings_to_json(settings_)}, {"soundpacks", packs}, {"sound_devices", std::move(devices)}}); diff --git a/core/src/store/app_settings.cpp b/core/src/store/app_settings.cpp index 877883c..380603c 100644 --- a/core/src/store/app_settings.cpp +++ b/core/src/store/app_settings.cpp @@ -82,6 +82,14 @@ std::vector> items_from_json(const json& arr, FromKey from_key AppSettings settings_from_json(const json& root) { AppSettings settings; + if (auto it = root.find("push_alerts"); it != root.end() && it->is_object()) { + for (const auto& def : push_alert_catalog) { + auto value = it->find(def.key); + if (value != it->end() && value->is_boolean()) { + settings.push_alerts.*(def.enabled) = value->get(); + } + } + } settings.sounds_enabled = root.value("sounds_enabled", true); settings.sound_volume = root.value("sound_volume", 100); settings.media_volume = root.value("media_volume", 100); @@ -207,6 +215,10 @@ AppSettings settings_from_json(const json& root) { json settings_to_json(const AppSettings& settings) { json root; + root["push_alerts"] = json::object(); + for (const auto& def : push_alert_catalog) { + root["push_alerts"][def.key] = settings.push_alerts.*(def.enabled); + } root["sounds_enabled"] = settings.sounds_enabled; root["sound_volume"] = settings.sound_volume; root["media_volume"] = settings.media_volume; diff --git a/docs/changelog.txt b/docs/changelog.txt index 43428f8..0c068ac 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -2,6 +2,7 @@ FastSMRW changelog ================== 0.5.8 ----- +- New: on iPhone and Android, choose which types of Mastodon push notifications you receive in Settings, Notifications. - Fixed: Open Links now finds Bluesky links hidden behind descriptive text, not just web addresses written out in the post. 0.5.7 diff --git a/ios/src/PushManager.swift b/ios/src/PushManager.swift index 0f58bba..421bd6d 100644 --- a/ios/src/PushManager.swift +++ b/ios/src/PushManager.swift @@ -48,6 +48,16 @@ final class PushManager: NSObject { /// Set while an explicit enable is in flight, so the core speaks the result /// only for a change the user asked for -- not for the renewal at launch. private var announceNextResult = false + private var accountKeys: Set = [] + + /// Renew after accounts have loaded, and whenever another account is added. + @MainActor + func accountsChanged(state: AppState) { + let keys = Set(state.accounts.filter { $0.platform == "mastodon" }.map { $0.key }) + let added = !keys.subtracting(accountKeys).isEmpty + accountKeys = keys + if added { refreshIfEnabled(state: state) } + } /// Turn push on: remember the choice, ask permission, register with APNs, /// and (once the token arrives) subscribe with the relay + Mastodon. @@ -64,6 +74,7 @@ final class PushManager: NSObject { func disable(state: AppState) { self.state = state UserDefaults.standard.set(false, forKey: Self.enabledKey) + announceNextResult = false state.pushUnsubscribe(announce: true) UIApplication.shared.unregisterForRemoteNotifications() } @@ -134,9 +145,10 @@ final class PushManager: NSObject { self.log.error("relay register: bad response (\(code, privacy: .public))") return } - let announce = self.announceNextResult - self.announceNextResult = false Task { @MainActor in + guard self.isEnabled else { return } + let announce = self.announceNextResult + self.announceNextResult = false self.state?.pushSubscribe(endpoint: endpoint, p256dh: keys.p256dh, auth: keys.auth, announce: announce) } diff --git a/ios/src/RootViewController.swift b/ios/src/RootViewController.swift index 98bb1d9..2125155 100644 --- a/ios/src/RootViewController.swift +++ b/ios/src/RootViewController.swift @@ -31,7 +31,11 @@ final class RootViewController: UIViewController { super.viewDidLoad() view.backgroundColor = .systemBackground - state.onAccountsChanged = { [weak self] in self?.refreshChildren() } + state.onAccountsChanged = { [weak self] in + guard let self else { return } + self.refreshChildren() + PushManager.shared.accountsChanged(state: self.state) + } state.onAuthResult = { [weak self] result in self?.handleAuthResult(result) } state.onOpenURL = { [weak self] url in if let add = self?.activeAddAccount, add.handleOpenURL(url) { return } diff --git a/ios/src/SceneDelegate.swift b/ios/src/SceneDelegate.swift index 2eafa8f..6f61ff6 100644 --- a/ios/src/SceneDelegate.swift +++ b/ios/src/SceneDelegate.swift @@ -46,8 +46,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { self.window = window state.start() - // If push was left on, re-register + re-subscribe (subscriptions lapse). - PushManager.shared.refreshIfEnabled(state: state) + // RootViewController renews push once accounts have finished loading. for context in connectionOptions.urlContexts { handle(context.url) } } diff --git a/ios/src/SettingsViewController.swift b/ios/src/SettingsViewController.swift index a8be0ff..371a1dd 100644 --- a/ios/src/SettingsViewController.swift +++ b/ios/src/SettingsViewController.swift @@ -26,6 +26,7 @@ enum SettingRow { case toggle(String, key: String, def: Bool) // A device-side on/off backed by PushManager (not a core setting). case pushToggle(String) + case pushAlert(String, key: String) case picker(String, key: String, options: [(String, Any)], def: Any) case stepper(String, key: String, def: Int, min: Int, max: Int, step: Int) case slider(String, key: String, def: Int, min: Int, max: Int) @@ -82,10 +83,10 @@ final class SettingsViewController: UITableViewController { ]), SettingPanel(title: "Notifications", footer: "Get notified of mentions, boosts, favorites and more while the app " - + "is closed. Mastodon accounts only.", + + "is closed. These choices apply to all Mastodon accounts on this device.", rows: [ .pushToggle("Push notifications"), - ]), + ] + state.pushAlertTypes.map { .pushAlert($0.label, key: $0.key) }), SettingPanel(title: "Timelines", footer: "Check timelines for new posts on this interval; new posts play " + "that timeline's sound.", @@ -241,6 +242,12 @@ final class SettingsPanelViewController: UITableViewController { if on { PushManager.shared.enable(state: self.state) } else { PushManager.shared.disable(state: self.state) } } + case let .pushAlert(title, key): + let on = (state.settingsRaw["push_alerts"] as? [String: Bool])?[key] ?? true + return ToggleCell(title: title, on: on) { [weak self] on in + self?.state.setPushAlert(key, enabled: on, + applyToSubscriptions: PushManager.shared.isEnabled) + } case let .picker(title, key, options, def): let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) var content = cell.defaultContentConfiguration() @@ -306,6 +313,11 @@ final class SettingsPanelViewController: UITableViewController { if on { PushManager.shared.enable(state: state) } else { PushManager.shared.disable(state: state) } (tableView.cellForRow(at: indexPath) as? ToggleCell)?.set(on: on) + case let .pushAlert(_, key): + let on = !((state.settingsRaw["push_alerts"] as? [String: Bool])?[key] ?? true) + state.setPushAlert(key, enabled: on, + applyToSubscriptions: PushManager.shared.isEnabled) + (tableView.cellForRow(at: indexPath) as? ToggleCell)?.set(on: on) default: break } diff --git a/tests/main.cpp b/tests/main.cpp index 4fa26fa..36504a4 100644 --- a/tests/main.cpp +++ b/tests/main.cpp @@ -130,6 +130,12 @@ void test_marker_restore_reports_already_there(); void test_note_selection_same_row_is_not_a_move(); void test_restored_position_survives_default_edge_echo(); +// From test_push.cpp +void test_push_settings(); +void test_push_requests(); +void test_push_session(); +void test_push_without_accounts(); + static void test_version() { CHECK(fastsm::version() != nullptr); CHECK(std::strlen(fastsm::version()) > 0); @@ -241,6 +247,10 @@ int main() { test_marker_restore_reports_already_there(); test_note_selection_same_row_is_not_a_move(); test_restored_position_survives_default_edge_echo(); + test_push_settings(); + test_push_requests(); + test_push_session(); + test_push_without_accounts(); std::printf("%d checks, %d failures\n", fastsmtest::checks(), fastsmtest::failures()); return fastsmtest::failures() == 0 ? 0 : 1; diff --git a/tests/test_push.cpp b/tests/test_push.cpp new file mode 100644 index 0000000..0a6b6f9 --- /dev/null +++ b/tests/test_push.cpp @@ -0,0 +1,270 @@ +#include "check.hpp" + +#include +#include +#include +#include +#include +#include + +#include "fastsm/platform/mastodon/mastodon_account.hpp" +#include "fastsm/session/core_session.hpp" +#include "fastsm/store/app_config.hpp" +#include "fastsm/store/settings_json.hpp" + +using namespace fastsm; +using nlohmann::json; + +namespace { +struct PushHttp : net::IHttpClient { + std::mutex mutex; + std::vector requests; + std::atomic second_status{200}; + + net::HttpResponse send(const net::HttpRequest& request) override { + net::HttpResponse response; + response.status = 200; + response.body = "[]"; + if (request.url.find("/api/v1/push/subscription") != std::string::npos) { + std::lock_guard lock(mutex); + requests.push_back(request); + response.body = "{}"; + if (request.url.find("https://two.example/") == 0) { + response.status = second_status.load(); + } + } + return response; + } + + std::vector take_requests() { + std::lock_guard lock(mutex); + auto result = std::move(requests); + requests.clear(); + return result; + } +}; + +bool has_alert(const net::HttpRequest& request, const std::string& key, bool enabled) { + const std::string field = "data%5Balerts%5D%5B" + key + "%5D=" + (enabled ? "true" : "false"); + return ("&" + request.body + "&").find("&" + field + "&") != std::string::npos; +} + +struct PushSession { + std::filesystem::path dir = std::filesystem::temp_directory_path() / + ("fastsmrw_push_test_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::mutex mutex; + std::condition_variable cv; + std::vector events; + PushHttp* http = nullptr; + std::unique_ptr session; + + explicit PushSession(int account_count = 2) { + std::filesystem::create_directories(dir); + store::AppConfig config; + config.settings.sounds_enabled = false; + config.settings.auto_refresh_seconds = 0; + config.settings.streaming_enabled = false; + for (int i = 0; i < account_count; ++i) { + store::AccountRecord account; + account.me.id = std::to_string(i + 1); + account.me.acct = i == 0 ? "alice@one.example" : "bob@two.example"; + account.account_key = "mastodon:" + account.me.id; + account.credential.mastodon = MastodonCredentials{ + i == 0 ? "https://one.example" : "https://two.example", "", "", "test-token"}; + config.accounts.push_back(account); + } + CHECK(store::AppConfigStore(dir / "config.json").save(config)); + start(); + } + + void start() { + auto transport = std::make_unique(); + http = transport.get(); + CoreSession::Paths paths; + paths.config_dir = dir; + session = std::make_unique(paths, std::move(transport), [this](const std::string& event) { + { + std::lock_guard lock(mutex); + events.push_back(json::parse(event)); + } + cv.notify_all(); + }); + session->dispatch(R"({"cmd":"start"})"); + const auto settings = wait("settings"); + CHECK_EQ(settings.value("push_alert_types", json::array()).size(), size_t(8)); + } + + json wait(const std::string& name) { + std::unique_lock lock(mutex); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + for (;;) { + for (auto it = events.begin(); it != events.end(); ++it) { + if (it->value("event", "") == name) { + json result = *it; + events.erase(it); + return result; + } + } + if (cv.wait_until(lock, deadline) == std::cv_status::timeout) { + CHECK(false); + return json::object(); + } + } + } + + void subscribe() { + session->dispatch(R"({"cmd":"push_subscribe","endpoint":"https://relay.example/device","p256dh":"key","auth":"secret"})"); + CHECK(wait("push_subscribe_result").value("ok", false)); + } + + ~PushSession() { + session.reset(); + std::error_code ec; + std::filesystem::remove_all(dir, ec); + } +}; +} // namespace + +void test_push_settings() { + const auto defaults = store::settings_to_json(store::settings_from_json(json::object())); + CHECK_EQ(defaults["push_alerts"].size(), size_t(8)); + for (const auto& value : defaults["push_alerts"]) { + CHECK(value == true); + } + // Older/incomplete configurations keep missing types on; malformed values + // cannot crash startup, and unknown server types are not silently enabled. + const auto partial = store::settings_from_json({{"push_alerts", + {{"mention", false}, {"poll", nullptr}, {"unknown", true}}}}); + const auto encoded = store::settings_to_json(partial); + CHECK(!encoded["push_alerts"]["mention"].get()); + CHECK(encoded["push_alerts"]["poll"].get()); + CHECK(encoded["push_alerts"]["follow"].get()); + CHECK(!encoded["push_alerts"].contains("unknown")); + CHECK_EQ(store::settings_to_json(store::settings_from_json(encoded))["push_alerts"], encoded["push_alerts"]); + CHECK(store::settings_from_json({{"push_alerts", nullptr}}).push_alerts.mention); + PushAlerts off; + for (const auto& def : push_alert_catalog) { + off.*(def.enabled) = false; + } + store::AppSettings settings; + settings.push_alerts = off; + const auto restored = store::settings_from_json(store::settings_to_json(settings)); + for (const auto& def : push_alert_catalog) { + CHECK(!(restored.push_alerts.*(def.enabled))); + } +} + +void test_push_requests() { + PushHttp http; + MastodonAccount account({"https://two.example", "", "", "test-token"}, {}, &http); + PushAlerts alerts; + alerts.favourite = false; + alerts.status = false; + CHECK(account.subscribe_push("https://relay.example/device", "key", "secret", alerts) == PushSubscribe::Ok); + CHECK(account.update_push_alerts(alerts) == PushSubscribe::Ok); + auto requests = http.take_requests(); + CHECK_EQ(requests.size(), size_t(2)); + if (requests.size() == 2) { + CHECK_EQ(requests[0].method, std::string("POST")); + CHECK_EQ(requests[1].method, std::string("PUT")); + CHECK(requests[0].body.find("subscription%5Bendpoint%5D=") != std::string::npos); + CHECK(requests[1].body.find("subscription") == std::string::npos); + for (const auto& request : requests) { + for (const auto& key : {"mention", "reblog", "follow", "follow_request", "poll", "update"}) { + CHECK(has_alert(request, key, true)); + } + CHECK(has_alert(request, "favourite", false)); + CHECK(has_alert(request, "status", false)); + } + } + for (const auto& def : push_alert_catalog) { + alerts.*(def.enabled) = false; + } + CHECK(account.update_push_alerts(alerts) == PushSubscribe::Ok); + requests = http.take_requests(); + CHECK_EQ(requests.size(), size_t(1)); + if (!requests.empty()) { + CHECK(requests[0].body.find("true") == std::string::npos); + for (const auto& def : push_alert_catalog) { + CHECK(has_alert(requests[0], def.key, false)); + } + } + http.second_status = 403; + CHECK(account.update_push_alerts(alerts) == PushSubscribe::NeedsReauth); + http.second_status = 503; + CHECK(account.update_push_alerts(alerts) == PushSubscribe::Failed); + http.second_status = 404; + CHECK(account.update_push_alerts(alerts) == PushSubscribe::Failed); +} + +void test_push_session() { + PushSession fixture; + // Saving while the master switch is off must not create or update pushes. + fixture.session->dispatch(R"({"cmd":"push_update_alerts","alerts":{"mention":false}})"); + CHECK(fixture.wait("push_update_alerts_result").value("ok", false)); + CHECK(fixture.http->take_requests().empty()); + fixture.subscribe(); + auto requests = fixture.http->take_requests(); + CHECK_EQ(requests.size(), size_t(2)); + for (const auto& request : requests) { + CHECK(has_alert(request, "mention", false)); + CHECK(has_alert(request, "follow", true)); + } + // Rapid patches must accumulate and the queued disable must run last. + // A failure for one account must not obscure the successful other account. + fixture.http->second_status = 403; + fixture.session->dispatch(R"({"cmd":"push_update_alerts","alerts":{"reblog":false},"apply_to_subscriptions":true})"); + fixture.session->dispatch(R"({"cmd":"push_update_alerts","alerts":{"follow":false},"apply_to_subscriptions":true})"); + fixture.session->dispatch(R"({"cmd":"push_unsubscribe"})"); + for (int i = 0; i < 2; ++i) { + const auto result = fixture.wait("push_update_alerts_result"); + CHECK(!result.value("ok", true)); + const auto accounts = result.value("accounts", json::array()); + CHECK_EQ(accounts.size(), size_t(2)); + if (accounts.size() == 2) { + CHECK(accounts[0].value("ok", false)); + CHECK(!accounts[1].value("ok", true)); + CHECK_EQ(accounts[1].value("reason", ""), std::string("reauth")); + CHECK_EQ(accounts[1].value("account_key", ""), std::string("mastodon:2")); + } + } + fixture.wait("push_unsubscribe_result"); + requests = fixture.http->take_requests(); + CHECK_EQ(requests.size(), size_t(6)); + if (requests.size() == 6) { + for (size_t i = 0; i < 4; ++i) { + CHECK_EQ(requests[i].method, std::string("PUT")); + CHECK(has_alert(requests[i], "mention", false)); + CHECK(has_alert(requests[i], "reblog", false)); + CHECK(has_alert(requests[i], "follow", i < 2)); + } + CHECK_EQ(requests[4].method, std::string("DELETE")); + CHECK_EQ(requests[5].method, std::string("DELETE")); + } + // Failed changes survive a full session restart and are used on renewal. + fixture.session.reset(); + { + std::lock_guard lock(fixture.mutex); + fixture.events.clear(); + } + fixture.start(); + fixture.subscribe(); + requests = fixture.http->take_requests(); + CHECK_EQ(requests.size(), size_t(2)); + for (const auto& request : requests) { + CHECK(has_alert(request, "mention", false)); + CHECK(has_alert(request, "reblog", false)); + CHECK(has_alert(request, "follow", false)); + } +} + +void test_push_without_accounts() { + PushSession fixture(0); + fixture.session->dispatch(R"({"cmd":"push_update_alerts","alerts":{"poll":false},"apply_to_subscriptions":true})"); + const auto result = fixture.wait("push_update_alerts_result"); + CHECK(!result.value("ok", true)); + CHECK_EQ(result.value("reason", ""), std::string("unsupported")); + CHECK(fixture.http->take_requests().empty()); + fixture.session.reset(); // drain the queued config save before reading it + CHECK(!store::AppConfigStore(fixture.dir / "config.json").load().settings.push_alerts.poll); +}