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
5 changes: 5 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,10 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>

<!-- Runs a push notification's Reply, Favourite or Boost button. -->
<receiver
android:name=".push.PushActionReceiver"
android:exported="false" />
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
4 changes: 4 additions & 0 deletions core/include/fastsm/platform/mastodon/mastodon_account.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ class MastodonAccount : public SocialAccount {
std::optional<User> lookup_user(const std::string& handle) override;
std::optional<Visibility> default_visibility() const override { return default_visibility_; }
std::optional<Status> 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<Status> notification_status(const std::string& id) override;
std::vector<User> search_accounts(const std::string& query, int limit) override;
FullRelationResult fetch_all_relations(const std::string& id, bool following) override;
std::optional<Relationship> relationship(const std::string& id) override;
Expand Down
6 changes: 6 additions & 0 deletions core/include/fastsm/platform/social_account.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<Status> 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;
Expand Down
3 changes: 3 additions & 0 deletions core/include/fastsm/session/core_session.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions core/src/platform/mastodon/mastodon_account.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,26 @@ std::optional<Status> MastodonAccount::fetch_status(const std::string& id) {
}
}

std::optional<Status> 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<User> 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
Expand Down
54 changes: 54 additions & 0 deletions core/src/session/core_session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<Status> 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<SocialAccount*> targets;
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions tests/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Loading