diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c10334a..c5d95ea 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -38,5 +38,10 @@ + + + diff --git a/android/app/src/main/java/me/masonasons/fastsm/push/FastSmMessagingService.kt b/android/app/src/main/java/me/masonasons/fastsm/push/FastSmMessagingService.kt index 5dc4390..f496291 100644 --- a/android/app/src/main/java/me/masonasons/fastsm/push/FastSmMessagingService.kt +++ b/android/app/src/main/java/me/masonasons/fastsm/push/FastSmMessagingService.kt @@ -41,6 +41,8 @@ class FastSmMessagingService : FirebaseMessagingService() { var text = "New notification" var id = 0 var type = "" + var notificationId = "" + var token = "" if (blob != null) { val payload = runCatching { @@ -69,10 +71,14 @@ class FastSmMessagingService : FirebaseMessagingService() { // picks the channel, so mentions and boosts can sound // different (or not at all) in Android's own settings. type = o.optString("notification_type") + // Mastodon sends the notification's id and the account's + // token, which the Reply/Favourite/Boost buttons act with. + notificationId = o.optString("notification_id") + token = o.optString("access_token") } } } - show(applicationContext, id, title, text, type) + show(applicationContext, id, title, text, type, notificationId, token) } companion object { @@ -121,7 +127,15 @@ class FastSmMessagingService : FirebaseMessagingService() { if (types.isNotEmpty()) manager.deleteNotificationChannel(LEGACY_CHANNEL) } - private fun show(context: Context, id: Int, title: String, text: String, type: String) { + private fun show( + context: Context, + id: Int, + title: String, + text: String, + type: String, + notificationId: String, + token: String, + ) { ensureChannels(context) // An unknown type, or one whose channel somehow isn't there yet, // falls back rather than vanishing. @@ -144,6 +158,7 @@ class FastSmMessagingService : FirebaseMessagingService() { .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) .setContentIntent(tap) + .also { PushActionReceiver.addActions(context, it, id, type, token, notificationId) } .build() if (!PushManager.hasNotificationPermission(context)) return runCatching { NotificationManagerCompat.from(context).notify(id, notification) } diff --git a/android/app/src/main/java/me/masonasons/fastsm/push/PushActionReceiver.kt b/android/app/src/main/java/me/masonasons/fastsm/push/PushActionReceiver.kt new file mode 100644 index 0000000..e4162ae --- /dev/null +++ b/android/app/src/main/java/me/masonasons/fastsm/push/PushActionReceiver.kt @@ -0,0 +1,130 @@ +package me.masonasons.fastsm.push + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import me.masonasons.fastsm.core.FastSmCore +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +/** + * Runs a push notification's Reply, Favourite or Boost button. + * + * The press can come with the app closed, so this starts its own short-lived + * core and sends it push_action; the core finds the account by the push's + * access token, looks the notification's post up and acts on it. The outcome is + * a toast, which screen readers read, and the notification goes once it worked. + */ +class PushActionReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val action = intent.getStringExtra(EXTRA_ACTION) ?: return + val token = intent.getStringExtra(EXTRA_TOKEN) ?: return + val notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID) ?: return + val shownId = intent.getIntExtra(EXTRA_SHOWN_ID, 0) + val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_REPLY)?.toString()?.trim() + val app = context.applicationContext + val pending = goAsync() + thread(name = "fastsm-push-action") { + try { + val ok = run(app, token, notificationId, action, text) + if (ok) NotificationManagerCompat.from(app).cancel(shownId) + val message = when { + !ok -> "Couldn't ${verb(action)}. Open FastSMRW to try again." + action == ACTION_REPLY -> "Reply sent" + action == ACTION_BOOST -> "Boosted" + else -> "Favourited" + } + Handler(Looper.getMainLooper()).post { Toast.makeText(app, message, Toast.LENGTH_SHORT).show() } + } finally { + pending.finish() + } + } + } + + private fun run(context: Context, token: String, id: String, action: String, text: String?): Boolean { + val core = FastSmCore.create(context) + val done = CountDownLatch(1) + var ok = false + core.setEventListener { e -> + if (e.optString("event") == "push_action_result") { + ok = e.optBoolean("ok") + done.countDown() + } + } + core.dispatch("push_action") { + put("access_token", token) + put("notification_id", id) + put("action", action) + if (text != null) put("text", text) + } + // A broadcast has about ten seconds; leave room to post the toast. + val answered = done.await(8, TimeUnit.SECONDS) + core.destroy() + return answered && ok + } + + private fun verb(action: String) = when (action) { + ACTION_REPLY -> "send the reply" + ACTION_BOOST -> "boost" + else -> "favourite" + } + + companion object { + const val ACTION_REPLY = "reply" + const val ACTION_FAVOURITE = "favourite" + const val ACTION_BOOST = "boost" + private const val KEY_REPLY = "reply_text" + private const val EXTRA_ACTION = "action" + private const val EXTRA_TOKEN = "access_token" + private const val EXTRA_NOTIFICATION_ID = "notification_id" + private const val EXTRA_SHOWN_ID = "shown_id" + + /** Mastodon notification types that are about a post someone else wrote. */ + private val ACTIONABLE_TYPES = setOf("mention", "status") + + /** + * Adds Reply, Favourite and Boost to a mention's notification. Nothing is + * added without the account's token and the notification id from the push. + */ + fun addActions( + context: Context, + builder: NotificationCompat.Builder, + shownId: Int, + type: String, + token: String, + notificationId: String, + ) { + if (type !in ACTIONABLE_TYPES || token.isEmpty() || notificationId.isEmpty()) return + fun intent(action: String, index: Int, mutable: Boolean): PendingIntent { + val i = Intent(context, PushActionReceiver::class.java) + .putExtra(EXTRA_ACTION, action) + .putExtra(EXTRA_TOKEN, token) + .putExtra(EXTRA_NOTIFICATION_ID, notificationId) + .putExtra(EXTRA_SHOWN_ID, shownId) + // Reply's must be mutable so the system can add the typed text. + val flags = PendingIntent.FLAG_UPDATE_CURRENT or + (if (mutable) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_IMMUTABLE) + return PendingIntent.getBroadcast(context, shownId * 3 + index, i, flags) + } + val reply = RemoteInput.Builder(KEY_REPLY).setLabel("Reply").build() + builder + .addAction( + NotificationCompat.Action.Builder(0, "Reply", intent(ACTION_REPLY, 0, mutable = true)) + .addRemoteInput(reply) + .setAllowGeneratedReplies(false) + .build(), + ) + .addAction(0, "Favourite", intent(ACTION_FAVOURITE, 1, mutable = false)) + .addAction(0, "Boost", intent(ACTION_BOOST, 2, mutable = false)) + } + } +} diff --git a/core/include/fastsm/platform/mastodon/mastodon_account.hpp b/core/include/fastsm/platform/mastodon/mastodon_account.hpp index 0edfad6..00b3232 100644 --- a/core/include/fastsm/platform/mastodon/mastodon_account.hpp +++ b/core/include/fastsm/platform/mastodon/mastodon_account.hpp @@ -58,6 +58,10 @@ class MastodonAccount : public SocialAccount { std::optional lookup_user(const std::string& handle) override; std::optional default_visibility() const override { return default_visibility_; } std::optional fetch_status(const std::string& id) override; + bool owns_access_token(const std::string& token) const override { + return !token.empty() && token == credentials_.access_token; + } + std::optional notification_status(const std::string& id) override; std::vector search_accounts(const std::string& query, int limit) override; FullRelationResult fetch_all_relations(const std::string& id, bool following) override; std::optional relationship(const std::string& id) override; diff --git a/core/include/fastsm/platform/social_account.hpp b/core/include/fastsm/platform/social_account.hpp index 1627e66..3d210de 100644 --- a/core/include/fastsm/platform/social_account.hpp +++ b/core/include/fastsm/platform/social_account.hpp @@ -367,6 +367,12 @@ class SocialAccount { } // Remove this account's push subscription. Return success. virtual bool unsubscribe_push() { return false; } + // Whether a Web Push payload's access_token is this account's, so a button on + // a push notification acts as the account it was sent to. + virtual bool owns_access_token(const std::string& /*token*/) const { return false; } + // The post a notification is about (Mastodon /api/v1/notifications/:id), or + // nullopt when it has none or can't be fetched. Runs on the worker thread. + virtual std::optional notification_status(const std::string& /*id*/) { return std::nullopt; } // Update only alert types, preserving the subscription endpoint and keys. virtual PushSubscribe update_push_alerts(const PushAlerts& /*alerts*/) { return PushSubscribe::Failed; diff --git a/core/include/fastsm/session/core_session.hpp b/core/include/fastsm/session/core_session.hpp index cd40bb4..3bb5edd 100644 --- a/core/include/fastsm/session/core_session.hpp +++ b/core/include/fastsm/session/core_session.hpp @@ -155,6 +155,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?} + // A push notification's button: {access_token, notification_id, action: + // "favourite"|"boost"|"reply", text?} -> push_action_result. + void cmd_push_action(const nlohmann::json& cmd); // {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); diff --git a/core/src/platform/mastodon/mastodon_account.cpp b/core/src/platform/mastodon/mastodon_account.cpp index 759659c..7d3a621 100644 --- a/core/src/platform/mastodon/mastodon_account.cpp +++ b/core/src/platform/mastodon/mastodon_account.cpp @@ -1109,6 +1109,26 @@ std::optional MastodonAccount::fetch_status(const std::string& id) { } } +std::optional MastodonAccount::notification_status(const std::string& id) { + // GET /api/v1/notifications/:id -- a push payload carries only the + // notification's id; its buttons act on the post it is about. + if (id.empty()) + return std::nullopt; + const std::string url = credentials_.instance_url + "/api/v1/notifications/" + id; + std::string body; + long status = 0; + if (!request("GET", url, "", "", body, status)) + return std::nullopt; + try { + json j = json::parse(body); + if (!j.is_object() || !j.contains("status") || !j["status"].is_object()) + return std::nullopt; + return mastodon::map_status(j["status"]); + } catch (...) { + return std::nullopt; + } +} + std::optional MastodonAccount::lookup_user(const std::string& handle) { // Resolve a typed handle to a full account. /accounts/lookup takes the acct // form (user@domain, or just user for a local account); a leading '@' is diff --git a/core/src/session/core_session.cpp b/core/src/session/core_session.cpp index b8a57ad..7dae77c 100644 --- a/core/src/session/core_session.cpp +++ b/core/src/session/core_session.cpp @@ -695,6 +695,8 @@ void CoreSession::handle(const json& cmd) { cmd_push_subscribe(cmd); else if (c == "push_unsubscribe") cmd_push_unsubscribe(cmd); + else if (c == "push_action") + cmd_push_action(cmd); else if (c == "push_update_alerts") cmd_push_update_alerts(cmd); else if (c == "list_followed_hashtags") @@ -1824,6 +1826,58 @@ void CoreSession::cmd_push_subscribe(const json& cmd) { }); } +void CoreSession::cmd_push_action(const json& cmd) { + const std::string token = cmd.value("access_token", std::string{}); + const std::string id = cmd.value("notification_id", std::string{}); + const std::string action = cmd.value("action", std::string{}); + const std::string text = cmd.value("text", std::string{}); + auto finish = [this, id, action](bool ok) { + emit({{"event", "push_action_result"}, {"ok", ok}, {"action", action}, {"notification_id", id}}); + }; + auto run = [this, token, id, action, text, finish] { + SocialAccount* account = nullptr; + for (SocialAccount* a : accounts_.accounts()) + if (a && a->owns_access_token(token)) { + account = a; + break; + } + if (!account || id.empty() || (action == "reply" && text.empty())) { + finish(false); + return; + } + worker_.post([this, account, id, action, text, finish] { + bool ok = false; + if (std::optional s = account->notification_status(id)) { + if (action == "favourite") { + ok = account->favorite(*s); + } else if (action == "boost") { + ok = account->boost(*s); + } else if (action == "reply") { + // As a reply from the timeline: everyone in the thread is + // mentioned and the post's visibility is kept. + PostDraft draft; + draft.text = present::mention_prefix(*s, account->me()) + text; + draft.reply_to_id = s->id; + draft.visibility = s->visibility; + ok = account->post(draft).has_value(); + } + } + loop_.post([finish, ok] { finish(ok); }); + }); + }; + if (!accounts_.accounts().empty()) { + run(); + return; + } + // A notification button can be pressed with the app closed, in a core that + // was never started: load the accounts as start does, but build nothing else. + worker_.post([this, run] { + if (accounts_.accounts().empty()) + accounts_.load(store::AppConfigStore(config_path_).load()); + loop_.post(run); + }); +} + void CoreSession::cmd_push_unsubscribe(const json& cmd) { const bool announce = cmd.value("announce", false); std::vector targets; diff --git a/docs/changelog.txt b/docs/changelog.txt index 819bfd8..3b6a7af 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -4,6 +4,7 @@ FastSMRW changelog 0.6.1 ----- +- New: on Android, notifications for mentions now have Reply, Favourite and Boost buttons, so you can answer or react without opening the app. - Fixed: on Android, FastSMRW now installs on 32-bit phones, which previously refused it with "app not compatible" and no way around it. - Changed: @-mention autocomplete now suggests people you've seen go past in your timelines, instantly and without waiting on the server, and matches on display names as well as handles. - Fixed: completing a mention with the cursor in the middle of a half-typed handle no longer leaves the end of it behind, and no longer adds a second space when one is already there. diff --git a/tests/main.cpp b/tests/main.cpp index 0153b35..f03afa6 100644 --- a/tests/main.cpp +++ b/tests/main.cpp @@ -159,6 +159,7 @@ void test_push_settings(); void test_push_requests(); void test_push_session(); void test_push_without_accounts(); +void test_push_action(); static void test_version() { CHECK(fastsm::version() != nullptr); @@ -296,6 +297,7 @@ int main() { test_push_requests(); test_push_session(); test_push_without_accounts(); + test_push_action(); 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 index 0a6b6f9..36bbbab 100644 --- a/tests/test_push.cpp +++ b/tests/test_push.cpp @@ -268,3 +268,133 @@ void test_push_without_accounts() { fixture.session.reset(); // drain the queued config save before reading it CHECK(!store::AppConfigStore(fixture.dir / "config.json").load().settings.push_alerts.poll); } + +namespace { +// Answers a mention notification (id 77, about post 500) and records every +// request, so a push_action test can see what the core sent where. +struct ActionHttp : net::IHttpClient { + std::mutex mutex; + std::vector requests; + + net::HttpResponse send(const net::HttpRequest& request) override { + { + std::lock_guard lock(mutex); + requests.push_back(request); + } + const json status = {{"id", "500"}, {"created_at", "2026-10-20T00:00:00.000Z"}, + {"content", "

hi

"}, {"visibility", "direct"}, {"mentions", json::array()}, + {"account", {{"id", "9"}, {"username", "carol"}, {"acct", "carol@three.example"}, + {"display_name", "Carol"}}}}; + net::HttpResponse response; + response.status = 200; + if (request.url.find("/api/v1/notifications/77") != std::string::npos) + response.body = json{{"id", "77"}, {"type", "mention"}, {"status", status}}.dump(); + else if (request.url.find("/api/v1/notifications/") != std::string::npos) + response.status = 404; + else + response.body = status.dump(); + return response; + } + + std::vector take() { + std::lock_guard lock(mutex); + auto result = std::move(requests); + requests.clear(); + return result; + } +}; +} // namespace + +void test_push_action() { + // A core that was never started, as when a notification button is pressed + // with the app closed: push_action loads the accounts itself. + const auto dir = std::filesystem::temp_directory_path() / + ("fastsmrw_push_action_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(dir); + store::AppConfig config; + config.settings.sounds_enabled = false; + for (int i = 0; i < 2; ++i) { + store::AccountRecord account; + account.me.id = std::to_string(i + 1); + account.me.acct = i == 0 ? "alice" : "bob"; + account.account_key = "mastodon:" + account.me.id; + account.credential.mastodon = MastodonCredentials{ + i == 0 ? "https://one.example" : "https://two.example", "", "", "token-" + std::to_string(i + 1)}; + config.accounts.push_back(account); + } + CHECK(store::AppConfigStore(dir / "config.json").save(config)); + auto transport = std::make_unique(); + ActionHttp* http = transport.get(); + std::mutex mutex; + std::condition_variable cv; + std::vector results; + CoreSession::Paths paths; + paths.config_dir = dir; + auto session = std::make_unique(paths, std::move(transport), [&](const std::string& event) { + json e = json::parse(event); + if (e.value("event", "") != "push_action_result") + return; + { + std::lock_guard lock(mutex); + results.push_back(e); + } + cv.notify_all(); + }); + auto act = [&](const json& cmd) { + session->dispatch(cmd.dump()); + std::unique_lock lock(mutex); + CHECK(cv.wait_for(lock, std::chrono::seconds(5), [&] { return !results.empty(); })); + json result = results.empty() ? json::object() : results.front(); + results.clear(); + return result; + }; + + // The button acts as the account the push was sent to: bob, by his token. + json r = act({{"cmd", "push_action"}, {"access_token", "token-2"}, {"notification_id", "77"}, + {"action", "favourite"}}); + CHECK(r.value("ok", false)); + CHECK_EQ(r.value("action", ""), std::string("favourite")); + auto requests = http->take(); + CHECK_EQ(requests.size(), size_t(2)); + if (requests.size() == 2) { + CHECK_EQ(requests[0].url, std::string("https://two.example/api/v1/notifications/77")); + CHECK_EQ(requests[1].url, std::string("https://two.example/api/v1/statuses/500/favourite")); + CHECK_EQ(requests[1].method, std::string("POST")); + } + + r = act({{"cmd", "push_action"}, {"access_token", "token-1"}, {"notification_id", "77"}, {"action", "boost"}}); + CHECK(r.value("ok", false)); + requests = http->take(); + CHECK(!requests.empty() && requests.back().url == "https://one.example/api/v1/statuses/500/reblog"); + + // A reply mentions the author and keeps a direct message direct. + r = act({{"cmd", "push_action"}, {"access_token", "token-1"}, {"notification_id", "77"}, + {"action", "reply"}, {"text", "On my way"}}); + CHECK(r.value("ok", false)); + requests = http->take(); + CHECK(!requests.empty()); + if (!requests.empty()) { + const auto& post = requests.back(); + CHECK_EQ(post.url, std::string("https://one.example/api/v1/statuses")); + CHECK(post.body.find("in_reply_to_id=500") != std::string::npos); + CHECK(post.body.find("visibility=direct") != std::string::npos); + CHECK(post.body.find("carol%40three.example") != std::string::npos); + CHECK(post.body.find("On") != std::string::npos); + } + + // An unknown token, an empty reply, or a notification without a post fail + // without acting. + CHECK(!act({{"cmd", "push_action"}, {"access_token", "stale"}, {"notification_id", "77"}, + {"action", "favourite"}}).value("ok", true)); + CHECK(!act({{"cmd", "push_action"}, {"access_token", "token-1"}, {"notification_id", "77"}, + {"action", "reply"}, {"text", ""}}).value("ok", true)); + CHECK(http->take().empty()); + CHECK(!act({{"cmd", "push_action"}, {"access_token", "token-1"}, {"notification_id", "78"}, + {"action", "favourite"}}).value("ok", true)); + requests = http->take(); + CHECK_EQ(requests.size(), size_t(1)); // the lookup, and no favourite + + session.reset(); + std::error_code ec; + std::filesystem::remove_all(dir, ec); +}