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
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import android.text.format.DateFormat
Expand Down Expand Up @@ -398,34 +397,27 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T
// Android manages badge counts automatically via notification channels.
}

@ReactMethod(isBlockingSynchronousMethod = true)
override fun peekPushTap(): WritableMap? {
val held = pushTap.peek() ?: return null
val tap = Arguments.createMap()
tap.putString("payload", held.payload)
tap.putDouble("id", held.id.toDouble())
return tap
}

@ReactMethod
override fun getInitialNotification(promise: Promise) {
// Clear on read so it behaves as a one-shot, matching iOS.
val bundle = KbModule.initialNotificationBundle
KbModule.initialNotificationBundle = null
if (bundle != null) {
try {
@Suppress("UNCHECKED_CAST")
val payload: WritableMap = Arguments.fromBundle(bundle) as WritableMap
promise.resolve(payload)
} catch (e: Exception) {
promise.resolve(null)
}
} else {
promise.resolve(null)
}
override fun ackPushTap(id: Double) {
pushTap.ack(id.toLong())
}

private fun emitPushNotificationInternal(notification: Bundle) {
private fun emitPushTapAvailableInternal() {
if (reactContext.hasActiveReactInstance() && canEmit()) {
try {
val payload = Arguments.fromBundle(notification)
emitOnPushNotification(payload)
emitOnPushTapAvailable()
} catch (e: Exception) {
NativeLogger.error("emitPushNotificationInternal failed to emit: " + e.message)
NativeLogger.error("emitPushTapAvailableInternal failed to emit: " + e.message)
}
} else {
NativeLogger.warn("emitPushNotificationInternal no active react instance")
}
}

Expand Down Expand Up @@ -489,18 +481,6 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T
// chance of being delivered before committing to it.
internal fun canDeliverReset(): Boolean = reactContext.hasActiveReactInstance() && canEmit()

// No current caller (kept for future use).
@ReactMethod
override fun engineReset() {
try {
Keybase.reset()
nativeResetRecv()
relayReset()
} catch (e: Exception) {
NativeLogger.error("Exception in engineReset", e)
}
}

@ReactMethod
override fun notifyJSReady() {
NativeLogger.info("JS signaled ready, starting ReadFromKBLib loop")
Expand Down Expand Up @@ -802,33 +782,20 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T
// visibility guarantee so the reader never sees a stale instance.
@Volatile
var instance: KbModule? = null
@JvmStatic
internal var initialNotificationBundle: Bundle? = null

@JvmStatic
fun keyPressed(keyName: String) {
instance?.sendHardwareKeyEvent(keyName)
}

@JvmStatic
fun setInitialNotification(bundle: Bundle?) {
initialNotificationBundle = bundle
}

@JvmStatic
fun isReactNativeRunning(): Boolean {
return instance != null
}
private val pushTap = PushTapSlot()

// Holds a tapped notification's data as JSON for peekPushTap, replacing
// any tap JS has not acked, and tells JS.
@JvmStatic
fun emitPushNotification(notification: Bundle) {
val module = instance
if (module == null) {
// NativeLogger writes to the Go service, which may not be up here.
android.util.Log.w("KbModule", "emitPushNotification called but instance is null (app may not be running)")
return
}
module.emitPushNotificationInternal(notification)
fun setPushTap(payloadJSON: String) {
pushTap.set(payloadJSON)
instance?.emitPushTapAvailableInternal()
}

// Written on the main thread by the process lifecycle observer, read on
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.reactnativekb

// The last tapped notification, held until JS acks its id. A new tap replaces
// any held one with a higher id; ids count taps of this process from 1. Written
// on the main thread, read and cleared on the JS thread.
class PushTapSlot {
data class Tap(val payload: String, val id: Long)

private var held: Tap? = null
private var lastID = 0L

@Synchronized
fun set(payload: String): Long {
lastID++
held = Tap(payload, lastID)
return lastID
}

// Does not clear: the tap stays until acked.
@Synchronized
fun peek(): Tap? = held

// No-op unless id is the held tap's.
@Synchronized
fun ack(id: Long) {
if (held?.id == id) {
held = null
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.reactnativekb

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

class PushTapSlotTest {
@Test
fun emptyUntilATap() {
assertNull(PushTapSlot().peek())
}

@Test
fun peekDoesNotClear() {
val slot = PushTapSlot()
val id = slot.set("{\"type\":\"chat.newmessage\"}")
assertEquals(PushTapSlot.Tap("{\"type\":\"chat.newmessage\"}", id), slot.peek())
assertEquals(PushTapSlot.Tap("{\"type\":\"chat.newmessage\"}", id), slot.peek())
}

@Test
fun ackWithAStaleIdDoesNotClear() {
val slot = PushTapSlot()
val older = slot.set("a")
val newer = slot.set("b")
slot.ack(older)
assertEquals(PushTapSlot.Tap("b", newer), slot.peek())
}

@Test
fun ackWithTheCurrentIdClears() {
val slot = PushTapSlot()
val id = slot.set("a")
slot.ack(id)
assertNull(slot.peek())
// acking again, or acking once empty, stays a no-op
slot.ack(id)
assertNull(slot.peek())
}

@Test
fun aNewTapReplacesWithAHigherId() {
val slot = PushTapSlot()
val first = slot.set("a")
val second = slot.set("b")
assertTrue(second > first)
assertEquals(PushTapSlot.Tap("b", second), slot.peek())
}

@Test
fun idsKeepCountingAfterAnAck() {
val slot = PushTapSlot()
val first = slot.set("a")
slot.ack(first)
val second = slot.set("b")
assertTrue(second > first)
}
}
8 changes: 3 additions & 5 deletions rnmodules/react-native-kb/ios/Kb.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,10 @@

// Push notification helpers - can be called from AppDelegate
FOUNDATION_EXPORT void KbSetDeviceToken(NSString *token);
FOUNDATION_EXPORT void KbSetInitialNotification(NSDictionary *notification);
FOUNDATION_EXPORT void KbEmitPushNotification(NSDictionary *notification);
// Main thread only. Holds a tapped notification's userInfo for peekPushTap,
// replacing any tap JS has not acked, and tells JS.
FOUNDATION_EXPORT void KbSetPushTap(NSDictionary *userInfo);
// Main thread only. Call next to each Go SetAppState* report with "active",
// "inactive" or "background"; the latest value is kept for getAppLifecycleState
// so JS can read what it missed before it listened.
FOUNDATION_EXPORT void KbEmitAppLifecycle(NSString *state);
// Re-emits a stored user-interaction notification once when the app becomes
// active (covers notification taps that arrive before React Native is ready).
FOUNDATION_EXPORT void KbEmitStoredNotificationOnBecomeActive(void);
119 changes: 53 additions & 66 deletions rnmodules/react-native-kb/ios/Kb.mm
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ + (id)sharedFsPathsHolder {
static std::mutex kbSharedInstanceMutex;
static BOOL kbPasteImageEnabled = NO;
static NSString *kbStoredDeviceToken = nil;
static NSDictionary *kbInitialNotification = nil;
// The last tapped notification, held until JS acks its id. Written on the main
// thread by the app delegate, read and cleared on the JS thread.
static std::mutex kbPushTapMutex;
static NSString *kbPushTapPayload = nil;
static int64_t kbPushTapID = 0;
// Written on the main thread by the app delegate, read on the JS thread by
// getAppLifecycleState.
static std::mutex kbAppLifecycleMutex;
Expand Down Expand Up @@ -501,21 +505,6 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime
RCT_EXPORT_METHOD(shareListenersRegistered) {
}

// No current caller (kept for future use).
RCT_EXPORT_METHOD(engineReset) {
NSError *error = nil;
KeybaseReset(&error);
if (auto bridge = kbGetBridge()) {
bridge->resetRecv();
}
if ([self canEmit]) {
[self emitOnMetaEvent:metaEventEngineReset];
}
if (error) {
NSLog(@"Error in reset: %@", error);
}
}

RCT_EXPORT_METHOD(notifyJSReady) {
// KeybaseNotifyJSReady is a sync.Once on the Go side, so repeat calls after
// a reload are free. It must not run on the JS thread — do it on the reader
Expand Down Expand Up @@ -803,16 +792,6 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime
});
}

RCT_EXPORT_METHOD(getInitialNotification: (RCTPromiseResolveBlock)resolve reject: (RCTPromiseRejectBlock)reject) {
if (kbInitialNotification) {
NSDictionary *notification = kbInitialNotification;
kbInitialNotification = nil;
resolve(notification);
} else {
resolve([NSNull null]);
}
}

RCT_EXPORT_METHOD(removeAllPendingNotificationRequests) {
UNUserNotificationCenter *current = UNUserNotificationCenter.currentNotificationCenter;
[current removeAllPendingNotificationRequests];
Expand Down Expand Up @@ -897,8 +876,52 @@ + (void)setDeviceToken:(NSString *)token {
});
}

+ (void)setInitialNotification:(NSDictionary *)notification {
kbInitialNotification = notification;
+ (void)setPushTap:(NSDictionary *)userInfo {
// String keys only, first one wins: JSON needs them, and describing an
// AnyHashable key can in principle collide.
NSMutableDictionary *payload = [NSMutableDictionary dictionaryWithCapacity:userInfo.count];
[userInfo enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) {
NSString *name = [key description];
if (!payload[name]) {
payload[name] = value;
}
}];
NSString *json = nil;
if ([NSJSONSerialization isValidJSONObject:payload]) {
NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
if (data) {
json = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
}
}
if (!json) {
// Still a tap: it opens the app, just nowhere in particular.
NSLog(@"Kb.setPushTap: payload could not be serialized");
json = @"{}";
}
{
std::lock_guard<std::mutex> lock(kbPushTapMutex);
kbPushTapPayload = json;
kbPushTapID++;
}
Kb *instance = kbSharedInstance;
if (instance && [instance canEmit]) {
[instance emitOnPushTapAvailable];
}
}

RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(peekPushTap) {
std::lock_guard<std::mutex> lock(kbPushTapMutex);
if (!kbPushTapPayload) {
return (id)kCFNull;
}
return @{@"payload" : kbPushTapPayload, @"id" : @(kbPushTapID)};
}

RCT_EXPORT_METHOD(ackPushTap : (double)tapID) {
std::lock_guard<std::mutex> lock(kbPushTapMutex);
if (kbPushTapPayload && (int64_t)tapID == kbPushTapID) {
kbPushTapPayload = nil;
}
}

+ (void)emitAppLifecycle:(NSString *)state {
Expand Down Expand Up @@ -952,16 +975,6 @@ + (KbLocationWatcher *)locationWatcher {
[[Kb locationWatcher] stop];
}

+ (void)emitPushNotification:(NSDictionary *)notification {
Kb *instance = kbSharedInstance;
if (instance && [instance canEmit]) {
[instance emitOnPushNotification:notification];
NSLog(@"Kb.emitPushNotification: sent event 'onPushNotification' to JS");
} else {
NSLog(@"Kb.emitPushNotification: WARNING - module not ready, event not sent");
}
}

- (void)handleHardwareKeyPressed:(NSNotification *)notification {
NSString *keyName = notification.userInfo[@"pressedKey"];
if (keyName && [self canEmit]) {
Expand Down Expand Up @@ -1008,36 +1021,10 @@ void KbSetDeviceToken(NSString *token) {
[Kb setDeviceToken:token];
}

void KbSetInitialNotification(NSDictionary *notification) {
[Kb setInitialNotification:notification];
}

void KbEmitPushNotification(NSDictionary *notification) {
[Kb emitPushNotification:notification];
void KbSetPushTap(NSDictionary *userInfo) {
[Kb setPushTap:userInfo];
}

void KbEmitAppLifecycle(NSString *state) {
[Kb emitAppLifecycle:state];
}

void KbEmitStoredNotificationOnBecomeActive(void) {
NSDictionary *stored = kbInitialNotification;
kbInitialNotification = nil;
if (!stored) {
NSLog(@"KbEmitStoredNotificationOnBecomeActive: no stored notification");
return;
}
if (![stored[@"userInteraction"] boolValue]) {
// Not from a user tap; nothing to re-emit.
return;
}
if ([stored[@"reEmittedInBecomeActive"] boolValue]) {
// Already re-emitted once; keep it stored for getInitialNotification.
kbInitialNotification = stored;
return;
}
[Kb emitPushNotification:stored];
NSMutableDictionary *copy = [stored mutableCopy];
copy[@"reEmittedInBecomeActive"] = @YES;
kbInitialNotification = copy;
}
Loading