Skip to content
2 changes: 2 additions & 0 deletions shared/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ dependencies {
implementation 'com.android.installreferrer:installreferrer:2.2'
implementation "androidx.lifecycle:lifecycle-common-java8:2.10.0"
implementation "androidx.lifecycle:lifecycle-process:2.10.0"

testImplementation "junit:junit:4.13.2"
}

// This requires a google-services.json file locally. Drop it in
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package io.keybase.ossifrage

import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

// Go's lifecycle entry points and the JS app-state event. Kept free of Android
// and gomobile calls so the event mapping and the push window run in JVM tests.
internal interface LifecycleBind {
fun setAppStateForeground()
fun setAppStateBackgroundActive()
fun isAppStateForeground(): Boolean
fun appDidEnterBackground(): Boolean
fun appBeginBackgroundTaskNonblock()
fun appWillExit()
fun emitAppLifecycle(state: String)
}

// Reports the whole process's visibility, not one activity's, to Go and JS
// together, so both see the same state. Process ON_STOP only fires once no
// activity is started, so moving between our own activities, dialogs and
// permission prompts never looks like a trip to the background.
//
// Calls reach Go on the calling thread, before the callback returns.
internal class AppLifecycleReporter(
private val bind: LifecycleBind,
private val log: (String) -> Unit,
) : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) = foreground("process onStart")

override fun onResume(owner: LifecycleOwner) = foreground("process onResume")

override fun onStop(owner: LifecycleOwner) {
report("process onStop") {
// appDidEnterBackground already reports BACKGROUND (and flushes) when
// it returns false; calling setAppStateBackground too would flush twice.
if (bind.appDidEnterBackground()) {
bind.appBeginBackgroundTaskNonblock()
}
}
bind.emitAppLifecycle("background")
}

fun onMainActivityResume() = foreground("MainActivity onResume")

// Activity recreation and a task moved to the back are not an exit.
fun onMainActivityDestroy(isFinishing: Boolean, isChangingConfigurations: Boolean) {
if (!isFinishing || isChangingConfigurations) {
return
}
report("MainActivity finishing") { bind.appWillExit() }
bind.emitAppLifecycle("background")
}

private fun foreground(event: String) {
report(event) { bind.setAppStateForeground() }
bind.emitAppLifecycle("active")
}

private fun report(event: String, call: () -> Unit) {
log("AppLifecycleReporter: $event")
try {
call()
} catch (e: Exception) {
log("AppLifecycleReporter: $event failed: $e")
}
}
}

// Sends a notification quick reply. Returns the text for the replied
// notification. notificationUID is the account the notification was shown for;
// Go posts as whichever account is current, so a reply from another account's
// notification is refused.
internal fun sendQuickReply(
currentUID: () -> String,
notificationUID: String,
msgId: Long,
error: (String, Throwable?) -> Unit,
send: () -> Unit,
): String {
val uid = try {
currentUID()
} catch (e: Exception) {
error("Quick reply couldn't read the current uid", e)
return QUICK_REPLY_FAILED
}
// Go sends before it checks either, and swallows the send's error.
if (uid.isEmpty()) {
error("Quick reply while logged out", null)
return QUICK_REPLY_FAILED
}
if (uid != notificationUID) {
error("Quick reply from another account's notification", null)
return QUICK_REPLY_FAILED
}
if (msgId < 0) {
error("Quick reply to invalid message id $msgId", null)
return QUICK_REPLY_FAILED
}
return try {
send()
QUICK_REPLY_SENT
} catch (e: Exception) {
error("Failed to send quick reply", e)
QUICK_REPLY_FAILED
}
}

// Runs a receiver's work off the main thread and calls finish exactly once:
// when the work ends or when budgetMs runs out, whichever is first, so the
// broadcast never outlives its limit. Work that overruns keeps going. An
// exception from work is logged, since it would otherwise kill the process.
internal fun runReceiverWork(
budgetMs: Long,
start: (Runnable) -> Unit,
warn: (String) -> Unit,
error: (String, Throwable) -> Unit,
finish: () -> Unit,
work: () -> Unit,
) {
val done = CountDownLatch(1)
start(Runnable {
try {
work()
} catch (e: Exception) {
error("runReceiverWork: work failed", e)
} finally {
done.countDown()
}
})
start(Runnable {
try {
if (!done.await(budgetMs, TimeUnit.MILLISECONDS)) {
warn("runReceiverWork: still running after ${budgetMs}ms, finishing the broadcast")
}
} finally {
finish()
}
})
}

internal const val QUICK_REPLY_SENT = "Replied"
internal const val QUICK_REPLY_FAILED = "Couldn't send reply"
Original file line number Diff line number Diff line change
Expand Up @@ -19,50 +19,53 @@ class ChatBroadcastReceiver : BroadcastReceiver() {
}

override fun onReceive(context: Context, intent: Intent) {
setupKBRuntime(context, false)
val convData = ConvData.fromIntent(intent)
val openConv = intent.getParcelableExtra<PendingIntent>("openConvPendingIntent")
val repliedNotification = NotificationCompat.Builder(context, KeybasePushNotificationListenerService.CHAT_CHANNEL_ID)
.setContentIntent(openConv)
.setTimeoutAfter(1000)
.setSmallIcon(R.drawable.ic_notif)
val notificationManager = NotificationManagerCompat.from(context)
val messageBody = getMessageText(intent)
if (messageBody != null) {
try {
val withBackgroundActive: WithBackgroundActive = object : WithBackgroundActive {
override fun task() {
val pendingResult = goAsync()
runReceiverWork(RECEIVER_BUDGET_MS, { Thread(it).start() }, { NativeLogger.warn(it) }, { msg, e -> NativeLogger.error(msg, e) },
{ pendingResult.finish() }) {
val status = if (messageBody == null) {
NativeLogger.error("Message Body in quick reply was null")
"Couldn't send reply - Failed to read input."
} else {
setupKBRuntime(context, false)
sendQuickReply({ Keybase.currentUID() }, convData.uid, convData.lastMsgId, { msg, e -> NativeLogger.error(msg, e) }) {
withBackgroundActive(KeybaseLifecycleBind(context), null, { NativeLogger.info(it) }) {
Keybase.handlePostTextReply(convData.convID, convData.tlfName, convData.lastMsgId, messageBody)
}
}
withBackgroundActive.whileActive(context)
repliedNotification.setContentText("Replied")
} catch (e: Exception) {
repliedNotification.setContentText("Couldn't send reply")
NativeLogger.error("Failed to send quick reply", e)
}
} else {
repliedNotification.setContentText("Couldn't send reply - Failed to read input.")
NativeLogger.error("Message Body in quick reply was null")
val repliedNotification = NotificationCompat.Builder(context, KeybasePushNotificationListenerService.CHAT_CHANNEL_ID)
.setContentIntent(openConv)
.setTimeoutAfter(1000)
.setSmallIcon(R.drawable.ic_notif)
.setContentText(status)
NotificationManagerCompat.from(context).notify(convData.convID, 0, repliedNotification.build())
}
notificationManager.notify(convData.convID, 0, repliedNotification.build())
}

companion object {
const val KEY_TEXT_REPLY = "key_text_reply"

// goAsync gives a broadcast 10s; leave margin.
private const val RECEIVER_BUDGET_MS = 9_000L
}
}

internal data class ConvData(
@JvmField val convID: String?,
val tlfName: String?,
val lastMsgId: Long
val lastMsgId: Long,
// The account the notification belongs to.
val uid: String,
) {
fun intoIntent(context: Context?): Intent {
val data = Bundle()
data.putString("convID", convID)
data.putString("tlfName", tlfName)
data.putLong("lastMsgId", lastMsgId)
data.putString("uid", uid)
val intent = Intent(context, ChatBroadcastReceiver::class.java)
intent.putExtra("ConvData", data)
return intent
Expand All @@ -74,7 +77,8 @@ internal data class ConvData(
return ConvData(
convID = data.getString("convID"),
tlfName = data.getString("tlfName"),
lastMsgId = data.getLong("lastMsgId")
lastMsgId = data.getLong("lastMsgId"),
uid = data.getString("uid") ?: "",
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ class KBPushNotifier internal constructor(private val context: Context, private
bundle.putString("uid", chatNotification.uid)
}
val pending_intent = buildPendingIntent(bundle)
val convData = ConvData(chatNotification.convID, chatNotification.tlfName ?: "", chatNotification.message.id)
val convData = ConvData(chatNotification.convID, chatNotification.tlfName ?: "", chatNotification.message.id, chatNotification.uid ?: "")
val builder = NotificationCompat.Builder(context, KeybasePushNotificationListenerService.CHAT_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notif)
.setContentTitle(chatNotification.title ?: "")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.keybase.ossifrage

import android.content.Context
import android.os.Bundle
import com.reactnativekb.KbModule
import keybase.Keybase

internal class KeybaseLifecycleBind(private val context: Context) : LifecycleBind {
override fun setAppStateForeground() = Keybase.setAppStateForeground()

override fun setAppStateBackgroundActive() = Keybase.setAppStateBackgroundActive()

override fun isAppStateForeground() = Keybase.isAppStateForeground()

override fun appDidEnterBackground() = Keybase.appDidEnterBackground()

override fun appBeginBackgroundTaskNonblock() = Keybase.appBeginBackgroundTaskNonblock(KBPushNotifier(context, Bundle()))

override fun appWillExit() = Keybase.appWillExit(KBPushNotifier(context, Bundle()))

override fun emitAppLifecycle(state: String) = KbModule.emitAppLifecycle(state)
}
Loading