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);
+}