From abc4ff1023ca93a60581c6ce0c9d63ab3a149669 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 23 Feb 2026 11:59:58 -0500 Subject: [PATCH 01/12] WIP --- shared/app/index.native.tsx | 12 +- .../markdown/service-decoration.tsx | 8 +- shared/constants/deeplinks.tsx | 147 +-------- .../constants/init/push-listener.native.tsx | 6 +- shared/constants/init/shared.tsx | 19 +- shared/constants/router.tsx | 156 +-------- .../renderer/remote-event-handler.desktop.tsx | 7 +- shared/router-v2/hooks.native.tsx | 195 ----------- shared/router-v2/linking.tsx | 311 ++++++++++++++++++ shared/router-v2/router.desktop.tsx | 11 +- shared/router-v2/router.native.tsx | 35 +- shared/stores/push.d.ts | 6 - shared/stores/push.desktop.tsx | 2 - shared/stores/push.native.tsx | 21 +- 14 files changed, 376 insertions(+), 560 deletions(-) create mode 100644 shared/router-v2/linking.tsx diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index d9f009250dad..d8c69b3848ee 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -2,11 +2,10 @@ import * as C from '@/constants' import {useConfigState} from '@/stores/config' import * as Kb from '@/common-adapters' import * as React from 'react' -import {handleAppLink} from '@/constants/deeplinks' import Main from './main.native' import {KeyboardProvider} from 'react-native-keyboard-controller' import Animated, {ReducedMotionConfig, ReduceMotion} from 'react-native-reanimated' -import {AppRegistry, AppState, Appearance, Linking, Keyboard} from 'react-native' +import {AppRegistry, AppState, Appearance, Keyboard} from 'react-native' import {PortalProvider} from '@/common-adapters/portal.native' import {SafeAreaProvider, initialWindowMetrics} from 'react-native-safe-area-context' import {makeEngine} from '../engine' @@ -110,15 +109,6 @@ const StoreHelper = (p: {children: React.ReactNode}): React.ReactNode => { useDarkHookup() useKeyboardHookup() - React.useEffect(() => { - const linkingSub = Linking.addEventListener('url', ({url}: {url: string}) => { - handleAppLink(url) - }) - return () => { - linkingSub.remove() - } - }, []) - return children } diff --git a/shared/common-adapters/markdown/service-decoration.tsx b/shared/common-adapters/markdown/service-decoration.tsx index 32f0c040ca6a..7c51bf39c799 100644 --- a/shared/common-adapters/markdown/service-decoration.tsx +++ b/shared/common-adapters/markdown/service-decoration.tsx @@ -1,7 +1,7 @@ import * as T from '@/constants/types' import * as React from 'react' import * as C from '@/constants' -import {handleAppLink} from '@/constants/deeplinks' +import {emitDeepLink} from '@/router-v2/linking' import * as Styles from '@/styles' import Channel from './channel' import KbfsPath from '@/fs/common/kbfs-path' @@ -32,7 +32,11 @@ type KeybaseLinkProps = { const KeybaseLink = (props: KeybaseLinkProps) => { const onClick = React.useCallback(() => { - handleAppLink(props.link) + // Route through the linking config for keybase:// and https://keybase.io/ URLs + // so React Navigation handles navigation declaratively. + // emitDeepLink normalizes URLs and dispatches through the linking config, + // falling back to handleAppLink for patterns not yet in the config. + emitDeepLink(props.link) }, [props.link]) return ( diff --git a/shared/constants/deeplinks.tsx b/shared/constants/deeplinks.tsx index db8d03a1b4be..c749ea26b62e 100644 --- a/shared/constants/deeplinks.tsx +++ b/shared/constants/deeplinks.tsx @@ -1,12 +1,8 @@ -import * as Tabs from './tabs' -import URL from 'url-parse' import logger from '@/logger' import * as T from '@/constants/types' -import {navigateAppend, switchTab} from './router' -import {storeRegistry} from '@/stores/store-registry' +import {navigateAppend} from './router' import {useChatState} from '@/stores/chat' import {useProfileState} from '@/stores/profile' -import {useSettingsPhoneState} from '@/stores/settings-phone' import {useTeamsState} from '@/stores/teams' const prefix = 'keybase://' @@ -26,76 +22,14 @@ const isTeamPageAction = (a?: string): a is TeamPageAction => { type TeamPageAction = 'add_or_invite' | 'manage_settings' | 'join' -// This logic is copied from go/protocol/keybase1/extras.go. const validTeamnamePart = (s: string): boolean => { if (s.length < 2 || s.length > 16) { return false } - return /^([a-zA-Z0-9][a-zA-Z0-9_]?)+$/.test(s) } const validTeamname = (s: string) => s.split('.').every(validTeamnamePart) -const handleShowUserProfileLink = (username: string) => { - switchTab(Tabs.peopleTab) - useProfileState.getState().dispatch.showUserProfile(username) -} - -const isKeybaseIoUrl = (url: URL) => { - const {protocol} = url - if (protocol !== 'http:' && protocol !== 'https:') return false - if (url.username || url.password) return false - const {hostname} = url - if (hostname !== 'keybase.io' && hostname !== 'www.keybase.io') return false - const {port} = url - if (port) { - if (protocol === 'http:' && port !== '80') return false - if (protocol === 'https:' && port !== '443') return false - } - return true -} - -const urlToUsername = (url: URL) => { - if (!isKeybaseIoUrl(url)) { - return null - } - // Adapted username regexp (see libkb/checkers.go) with a leading /, an - // optional trailing / and a dash for custom links. - const match = url.pathname.match(/^\/((?:[a-zA-Z0-9][a-zA-Z0-9_-]?)+)\/?$/) - if (!match) { - return null - } - const usernameMatch = match[1] - if (!usernameMatch || usernameMatch.length < 2 || usernameMatch.length > 16) { - return null - } - // Ignore query string and hash parameters. - return usernameMatch.toLowerCase() -} - -const urlToTeamDeepLink = (url: URL) => { - if (!isKeybaseIoUrl(url)) { - return null - } - // Similar regexp to username but allow `.` for subteams - const match = url.pathname.match(/^\/team\/((?:[a-zA-Z0-9][a-zA-Z0-9_.-]?)+)\/?$/) - if (!match) { - return null - } - const teamName = match[1] - if (!teamName || teamName.length < 2 || teamName.length > 255) { - return null - } - // `url.query` has a wrong type in @types/url-parse. It's a `string` in the - // code, but @types claim it's a {[k: string]: string | undefined}. - const queryString = url.query as unknown as string - // URLSearchParams is not available in react-native. See if any of recognized - // query parameters is passed using regular expressions. - const action = (['add_or_invite', 'manage_settings'] satisfies readonly TeamPageAction[]).find( - x => queryString.search(`[?&]applink=${x}([?&].+)?$`) !== -1 - ) - return {action, teamName} -} const handleTeamPageLink = (teamname: string, action?: TeamPageAction) => { useTeamsState @@ -108,77 +42,39 @@ const handleTeamPageLink = (teamname: string, action?: TeamPageAction) => { ) } +// Fallback handler for keybase:// URL patterns not yet handled by the linking config. +// Called by the linking config when customGetStateFromPath returns undefined. export const handleAppLink = (link: string) => { if (link.startsWith('keybase://')) { handleKeybaseLink(link.replace('keybase://', '')) - return - } else { - // Normal deeplink - const url = new URL(link) - const username = urlToUsername(url) - if (username === 'phone-app') { - const phoneState = useSettingsPhoneState.getState() - const phones = (phoneState as {phones?: Map}).phones - if (!phones || phones.size > 0) { - return - } - switchTab(Tabs.settingsTab) - navigateAppend('settingsAddPhone') - } else if (username && username !== 'app') { - handleShowUserProfileLink(username) - return - } - const teamLink = urlToTeamDeepLink(url) - if (teamLink) { - handleTeamPageLink(teamLink.teamName, teamLink.action) - return - } } } -export const handleKeybaseLink = (link: string) => { +// Handle keybase:// URL patterns that the linking config doesn't handle declaratively. +// Patterns handled by the linking config (convid, profile/show, private, public, +// incoming-share, settingsPushPrompt, tab switches) don't reach this function. +const handleKeybaseLink = (link: string) => { if (!link) return const error = "We couldn't read this link. The link might be bad, or your Keybase app might be out of date and needs to be updated." const parts = link.split('/') - // List guaranteed to contain at least one elem. switch (parts[0]) { case 'profile': if (parts[1] === 'new-proof' && (parts.length === 3 || parts.length === 4)) { parts.length === 4 && parts[3] && useProfileState.getState().dispatch.showUserProfile(parts[3]) useProfileState.getState().dispatch.addProof(parts[2]!, 'appLink') return - } else if (parts[1] === 'show' && parts.length === 3) { - // Username is basically a team name part, we can use the same logic to - // validate deep link. - const username = parts[2]! - if (username.length && validTeamnamePart(username)) { - return handleShowUserProfileLink(username) - } } break - // Fall-through - case 'private': - case 'public': case 'team': try { const decoded = decodeURIComponent(link) - switchTab(Tabs.fsTab) navigateAppend({props: {path: `/keybase/${decoded}`}, selected: 'fsRoot'}) return } catch { logger.warn("Coudn't decode KBFS URI") return } - case 'convid': - if (parts.length === 2) { - const conversationIDKey = parts[1] - if (conversationIDKey) { - storeRegistry.getConvoState(conversationIDKey).dispatch.navigateToThread('navChanged') - } - return - } - break case 'chat': if (parts.length === 2 || parts.length === 3) { if (parts[1]!.includes('#')) { @@ -219,7 +115,7 @@ export const handleKeybaseLink = (link: string) => { } } break - case 'team-page': // keybase://team-page/{team_name}/{manage_settings,add_or_invite}? + case 'team-page': if (parts.length >= 2) { const teamName = parts[1]! if (teamName.length && validTeamname(teamName)) { @@ -230,36 +126,11 @@ export const handleKeybaseLink = (link: string) => { } } break - case 'incoming-share': - // android needs to render first when coming back - setTimeout(() => { - navigateAppend('incomingShareNew') - }, 500) - return case 'team-invite-link': useTeamsState.getState().dispatch.openInviteLink(parts[1] ?? '', parts[2] || '') return - case 'settingsPushPrompt': - navigateAppend('settingsPushPrompt') - return - case Tabs.teamsTab: - switchTab(Tabs.teamsTab) - return - case Tabs.fsTab: - switchTab(Tabs.fsTab) - return - case Tabs.chatTab: - switchTab(Tabs.chatTab) - return - case Tabs.peopleTab: - switchTab(Tabs.peopleTab) - return - case Tabs.settingsTab: - switchTab(Tabs.settingsTab) - return default: - // Fall through to the error return below. + break } navigateAppend({props: {error}, selected: 'keybaseLinkError'}) } - diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index f96d32d5d8ac..35fcf178f0bf 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -1,7 +1,7 @@ import * as T from '@/constants/types' import {ignorePromise, timeoutPromise} from '@/constants/utils' import logger from '@/logger' -import {handleAppLink} from '@/constants/deeplinks' +import {emitDeepLink} from '@/router-v2/linking' import {isAndroid, isIOS} from '@/constants/platform' import { getRegistrationToken, @@ -246,9 +246,7 @@ export const initPushListener = () => { } else { return } - try { - handleAppLink('keybase://incoming-share') - } catch {} + emitDeepLink('keybase://incoming-share') }) shareListenersRegistered() } diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index b6a5f82f5d57..02616e6ce062 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -42,7 +42,7 @@ import type * as UseUnlockFoldersStateType from '@/stores/unlock-folders' import type * as UseUsersStateType from '@/stores/users' import {createTBStore, getTBStore} from '@/stores/team-building' import {getSelectedConversation} from '@/constants/chat/common' -import {handleKeybaseLink} from '@/constants/deeplinks' +import {emitDeepLink} from '@/router-v2/linking' import {ignorePromise} from '../utils' import {isMobile, serverConfigFileName} from '../platform' import {storeRegistry} from '@/stores/store-registry' @@ -331,18 +331,6 @@ export const initPushCallbacks = () => { onGetDaemonHandshakeState: () => { return useDaemonState.getState().handshakeState }, - onNavigateToThread: ( - conversationIDKey: T.Chat.ConversationIDKey, - reason: 'push' | 'extension', - pushBody?: string - ) => { - storeRegistry - .getConvoState(conversationIDKey) - .dispatch.navigateToThread(reason, undefined, pushBody) - }, - onShowUserProfile: (username: string) => { - useProfileState.getState().dispatch.showUserProfile(username) - }, }, }, }) @@ -893,7 +881,10 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { if (deferred && !link.startsWith('keybase://team-invite-link/')) { return } - handleKeybaseLink(link) + // Route through the linking config; it falls back to handleAppLink + // for URL patterns not handled declaratively. + const fullUrl = link.startsWith('keybase://') ? link : `keybase://${link}` + emitDeepLink(fullUrl) } break case EngineGen.keybase1NotifyTeamAvatarUpdated: { diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index e222c047efdb..87ccc7dab665 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -1,6 +1,6 @@ import type * as React from 'react' import type * as T from './types' -import * as Tabs from './tabs' +import type * as Tabs from './tabs' import { StackActions, CommonActions, @@ -11,9 +11,8 @@ import { } from '@react-navigation/core' import type {NavigateAppendType, RouteKeys, RootParamList as KBRootParamList} from '@/router-v2/route-params' import type {GetOptionsRet} from './types/router' -import {produce} from 'immer' -import isEqual from 'lodash/isEqual' -import {isMobile, isTablet} from './platform' +import {makeChatConversationState} from '@/router-v2/linking' +import {isMobile} from './platform' import {shallowEqual, type ViewPropsToPageProps} from './utils' import {registerDebugClear} from '@/util/debug' @@ -31,8 +30,6 @@ export type Navigator = NavigationContainerRef const DEBUG_NAV = __DEV__ && (false as boolean) -const isSplit = !isMobile || isTablet // Whether the inbox and conversation panels are visible side-by-side. - export const getRootState = (): NavState | undefined => { if (!navigationRef.isReady()) return return navigationRef.getRootState() @@ -131,51 +128,6 @@ export const logState = () => { return {loggedIn: _isLoggedIn(rs), modals, visible} } -type DeepWriteable = {-readonly [P in keyof T]: DeepWriteable} - -const navUpHelper = (s: DeepWriteable, name: string) => { - const route = s?.routes?.[s.index ?? -1] as DeepWriteable | undefined - if (!route) { - return - } - - // found? - if (route.name === name) { - // selected a root stack? choose just the root item - if (route.state?.type === 'stack') { - route.state.routes.length = 1 - route.state.index = 0 - } else { - // leave alone? maybe this never happens - route.state = undefined - } - return - } - - // search stack for target - if (route.state?.type === 'stack') { - const idx = route.state.routes.findIndex((r: {name: string}) => r.name === name) - // found - if (idx !== -1) { - route.state.index = idx - route.state.routes.length = idx + 1 - return - } - } - // try the incoming s - if (s?.type === 'stack') { - const idx: number = s.routes?.findIndex((r: {name: string}) => r.name === name) ?? -1 - // found - if (idx !== -1 && s.routes) { - s.index = idx - s.routes.length = idx + 1 - return - } - } - - navUpHelper(route.state, name) -} - export const getRouteTab = (route: Array) => { return route[1]?.name } @@ -233,21 +185,7 @@ export const navUpToScreen = (name: RouteKeys) => { DEBUG_NAV && console.log('[Nav] navUpToScreen', {name}) const n = _getNavigator() if (!n) return - const ns = getRootState() - // some kind of unknown race, just bail - if (!ns) { - console.log('Avoiding trying to nav to thread when missing nav state, bailing') - return - } - if (!ns.routes) { - console.log('Avoiding trying to nav to thread when malformed nav state, bailing') - return - } - - const nextState = produce(ns, draft => { - navUpHelper(draft as DeepWriteable, name) - }) - n.dispatch(CommonActions.reset(nextState as Parameters[0])) + n.dispatch(StackActions.popTo(name)) } export const navigateAppend = (path: PathParam, replace?: boolean) => { @@ -315,88 +253,16 @@ export const navToProfile = (username: string) => { export const navToThread = (conversationIDKey: T.Chat.ConversationIDKey) => { DEBUG_NAV && console.log('[Nav] navToThread', conversationIDKey) + const n = _getNavigator() + if (!n) return const rs = getRootState() - // some kind of unknown race, just bail - if (!rs) { - console.log('Avoiding trying to nav to thread when missing nav state, bailing') - return - } - if (!rs.routes) { - console.log('Avoiding trying to nav to thread when malformed nav state, bailing') - return - } + if (!rs?.key) return - const nextState = produce(rs, draft => { - const loggedInRoute = draft.routes?.[0] - const loggedInTabs = loggedInRoute?.state?.routes - if (!loggedInTabs) { - return - } - const chatTabIdx = loggedInTabs.findIndex((r: {name: string}) => r.name === Tabs.chatTab) - const chatStack = loggedInTabs[chatTabIdx] - - if (!chatStack) { - return - } - - // select tabs - draft.index = 0 - // remove modals - if (draft.routes) { - draft.routes.length = 1 - } - - // select chat tab - if (loggedInRoute.state) { - loggedInRoute.state.index = chatTabIdx - } - - const oldChatState = chatStack.state - - // setup root - chatStack.state = { - index: 0, - routes: [{key: oldChatState?.routes[0]?.key ?? 'chatRoot', name: 'chatRoot'}], - } - - if (isSplit) { - const _chatRoot = oldChatState?.routes[0] - // key is required or you'll run into issues w/ the nav - const chatRoot = { - key: _chatRoot?.key || `chatRoot-${conversationIDKey}`, - name: 'chatRoot', - params: {conversationIDKey}, - } as const - chatStack.state.routes = [chatRoot] - } else { - // key is required or you'll run into issues w/ the nav - let convoRoute = { - key: `chatConversation-${conversationIDKey}`, - name: 'chatConversation', - params: {conversationIDKey}, - } as const - // reuse visible route if it's the same - const visible = oldChatState?.routes.at(-1) - if (visible) { - const vParams: undefined | {conversationIDKey?: T.Chat.ConversationIDKey} = visible.params - if (visible.name === 'chatConversation' && vParams?.conversationIDKey === conversationIDKey) { - convoRoute = visible as typeof convoRoute - } - } - - const chatRoot = chatStack.state.routes[0] - chatStack.state.routes = [chatRoot, convoRoute] as typeof chatStack.state.routes - chatStack.state.index = 1 - } + const nextState = makeChatConversationState(conversationIDKey) + n.dispatch({ + ...CommonActions.reset(nextState as Parameters[0]), + target: rs.key, }) - - if (!isEqual(rs, nextState)) { - rs.key && - _getNavigator()?.dispatch({ - ...CommonActions.reset(nextState as Parameters[0]), - target: rs.key, - }) - } } export const appendPeopleBuilder = () => { diff --git a/shared/desktop/renderer/remote-event-handler.desktop.tsx b/shared/desktop/renderer/remote-event-handler.desktop.tsx index 0fe190435d46..6c0b11fff3e1 100644 --- a/shared/desktop/renderer/remote-event-handler.desktop.tsx +++ b/shared/desktop/renderer/remote-event-handler.desktop.tsx @@ -7,7 +7,7 @@ import {ignorePromise} from '@/constants/utils' import {switchTab} from '@/constants/router' import {storeRegistry} from '@/stores/store-registry' import {onEngineConnected, onEngineDisconnected} from '@/constants/init/index.desktop' -import {handleAppLink} from '@/constants/deeplinks' +import {emitDeepLink} from '@/router-v2/linking' import {isPathSaltpackEncrypted, isPathSaltpackSigned} from '@/util/path' import type HiddenString from '@/util/hidden-string' import {useConfigState} from '@/stores/config' @@ -150,10 +150,7 @@ export const eventFromRemoteWindows = (action: RemoteGen.Actions) => { break } case RemoteGen.link: - { - const {link} = action.payload - handleAppLink(link) - } + emitDeepLink(action.payload.link) break case RemoteGen.installerRan: useConfigState.getState().dispatch.installerRan() diff --git a/shared/router-v2/hooks.native.tsx b/shared/router-v2/hooks.native.tsx index 6273f149182a..87a4b817bffc 100644 --- a/shared/router-v2/hooks.native.tsx +++ b/shared/router-v2/hooks.native.tsx @@ -1,201 +1,6 @@ import * as C from '@/constants' -import * as Chat from '@/stores/chat' -import {useConfigState} from '@/stores/config' -import * as Tabs from '@/constants/tabs' import * as React from 'react' -import {handleAppLink} from '@/constants/deeplinks' -import {Linking} from 'react-native' import {useColorScheme} from 'react-native' -import {usePushState} from '@/stores/push' - -type InitialStateState = 'init' | 'loading' | 'loaded' - -const argArrayGood = (arr: Array, len: number) => { - return arr.length === len && arr.every(p => !!p.length) -} -const isValidLink = (link: string) => { - const urlPrefix = 'https://keybase.io/' - if (link.startsWith(urlPrefix)) { - if (link.substring(urlPrefix.length).split('/').length === 1) { - return true - } - } - const prefix = 'keybase://' - if (!link.startsWith(prefix)) { - return false - } - const path = link.substring(prefix.length) - const [root, ...parts] = path.split('/') - - switch (root) { - case 'profile': - switch (parts[0]) { - case 'new-proof': - return argArrayGood(parts, 2) || argArrayGood(parts, 3) - case 'show': - return argArrayGood(parts, 2) - default: - } - return false - case 'private': - return true - case 'public': - return true - case 'team': - return true - case 'convid': - return argArrayGood(parts, 1) - case 'chat': - return argArrayGood(parts, 1) || argArrayGood(parts, 2) - case 'team-page': - return argArrayGood(parts, 3) - case 'incoming-share': - return true - case 'team-invite-link': - return argArrayGood(parts, 1) - case 'settingsPushPrompt': - return true - default: - return false - } -} - -export const useInitialState = (loggedInLoaded: boolean) => { - const config = useConfigState( - C.useShallow(s => { - const {androidShare, loggedIn, startup} = s - return {androidShare, loggedIn, startup} - }) - ) - const {androidShare, loggedIn, startup} = config - const {tab: startupTab, followUser: startupFollowUser, loaded: startupLoaded} = startup - let {conversation: startupConversation} = startup - - if (!Chat.isValidConversationIDKey(startupConversation)) { - startupConversation = '' - } - - const showMonster = usePushState(s => { - const {hasPermissions, justSignedUp, showPushPrompt} = s - return loggedIn && !justSignedUp && showPushPrompt && !hasPermissions - }) - - const [initialState, setInitialState] = React.useState(undefined) - const [initialStateState, setInitialStateState] = React.useState('init') - - React.useEffect(() => { - if (!startupLoaded) { - return - } - - if (!loggedInLoaded) { - return - } - if (initialStateState !== 'init') { - return - } - setInitialStateState('loading') - const loadInitialURL = async () => { - let url = await Linking.getInitialURL() - - // don't try and resume or follow links if we're signed out - if (!loggedIn) { - return - } - - const haveSavedTab = !!(startupTab || startupConversation) - if (!url && showMonster && !haveSavedTab) { - url = 'keybase://settingsPushPrompt' - } - if (!url && androidShare && !haveSavedTab) { - url = `keybase://incoming-share` - } - - if (url && isValidLink(url)) { - setTimeout(() => url && handleAppLink(url), 1) - } else if (startupFollowUser && !startupConversation) { - url = `keybase://profile/show/${startupFollowUser}` - - if (isValidLink(url)) { - const initialTabState = { - state: { - index: 1, - routes: [{name: 'peopleRoot'}, {name: 'profile', params: {username: startupFollowUser}}], - }, - } - setInitialState({ - index: 0, - routes: [ - { - name: 'loggedIn', - state: { - index: 0, - routeNames: [Tabs.peopleTab], - routes: [{name: Tabs.peopleTab, ...initialTabState}], - }, - }, - ], - }) - } - } else if (startupTab || startupConversation) { - try { - const tab = startupConversation ? Tabs.chatTab : startupTab - Chat.useChatState.getState().dispatch.unboxRows([startupConversation]) - Chat.getConvoState(startupConversation).dispatch.loadMoreMessages({ - reason: 'savedLastState', - }) - - const initialTabState = startupConversation - ? { - state: { - index: 1, - routes: [ - {name: 'chatRoot'}, - {name: 'chatConversation', params: {conversationIDKey: startupConversation}}, - ], - }, - } - : {} - - setInitialState({ - index: 0, - routes: [ - { - name: 'loggedIn', - state: { - index: 0, - routeNames: [tab], - routes: [{name: tab, ...initialTabState}], - }, - }, - ], - }) - } catch {} - } else { - } - } - - const f = async () => { - await loadInitialURL() - setInitialStateState('loaded') - } - - C.ignorePromise(f()) - }, [ - androidShare, - initialState, - initialStateState, - loggedIn, - loggedInLoaded, - showMonster, - startupConversation, - startupFollowUser, - startupLoaded, - startupTab, - ]) - - return {initialState, initialStateState} -} // on android we rerender everything on dark mode changes export const useRootKey = () => { diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx new file mode 100644 index 000000000000..1f4fe59fd5c2 --- /dev/null +++ b/shared/router-v2/linking.tsx @@ -0,0 +1,311 @@ +import * as Tabs from '@/constants/tabs' +import {isSplit} from '@/constants/chat/common' +import {isValidConversationIDKey} from '@/constants/types/chat/common' +import {isMobile} from '@/constants/platform' +import {useConfigState} from '@/stores/config' +import {usePushState} from '@/stores/push' +import type {LinkingOptions} from '@react-navigation/native' +import type {RootParamList} from './route-params' + +// ---- URL normalization ---- + +// Convert https://keybase.io/ URLs to keybase:// URLs +const normalizeHttpUrl = (url: string): string | undefined => { + const protocolEnd = url.indexOf('://') + if (protocolEnd === -1) return undefined + const protocol = url.substring(0, protocolEnd + 3) + if (protocol !== 'http://' && protocol !== 'https://') return undefined + + const afterProtocol = url.substring(protocolEnd + 3) + const slashIdx = afterProtocol.indexOf('/') + const host = slashIdx === -1 ? afterProtocol : afterProtocol.substring(0, slashIdx) + // Strip port for comparison + const colonIdx = host.indexOf(':') + const hostname = colonIdx === -1 ? host : host.substring(0, colonIdx) + + if (hostname !== 'keybase.io' && hostname !== 'www.keybase.io') return undefined + + const pathname = slashIdx === -1 ? '/' : afterProtocol.substring(slashIdx).split('?')[0]! + + // /team/someteam?applink=action + const teamMatch = pathname.match(/^\/team\/((?:[a-zA-Z0-9][a-zA-Z0-9_.-]?)+)\/?$/) + if (teamMatch?.[1]) { + const teamName = teamMatch[1] + const queryIdx = url.indexOf('?') + const queryString = queryIdx === -1 ? '' : url.substring(queryIdx) + const actionMatch = queryString.match(/[?&]applink=([a-z_]+)/) + const action = actionMatch?.[1] + return action + ? `keybase://team-page/${teamName}/${action}` + : `keybase://team-page/${teamName}` + } + + // /username (single path segment) + const userMatch = pathname.match(/^\/((?:[a-zA-Z0-9][a-zA-Z0-9_-]?)+)\/?$/) + if (userMatch?.[1]) { + const username = userMatch[1].toLowerCase() + if (username !== 'app' && username.length >= 2 && username.length <= 16) { + return `keybase://profile/show/${username}` + } + } + + return undefined +} + +// Normalize any incoming URL to a keybase:// URL +export const normalizeUrl = (url: string): string | undefined => { + if (url.startsWith('keybase://')) return url + return normalizeHttpUrl(url) +} + +// ---- State building helpers ---- + +type PartialRoute = { + name: string + params?: Record + state?: PartialNavState +} + +type PartialNavState = { + routes: Array + index?: number +} + +// Build state for navigating to a screen within a tab +const makeTabState = ( + tab: string, + screenStack?: Array<{name: string; params?: Record}> +): PartialNavState => { + const tabRoute: PartialRoute = {name: tab} + if (screenStack && screenStack.length > 0) { + tabRoute.state = { + index: screenStack.length - 1, + routes: screenStack, + } + } + return { + routes: [{name: 'loggedIn', state: {routes: [tabRoute]}}], + } +} + +// Build state for navigating to a chat conversation +export const makeChatConversationState = (conversationIDKey: string): PartialNavState => { + if (isSplit) { + // Tablet/desktop: chatRoot with conversationIDKey param (split view) + return makeTabState(Tabs.chatTab, [{name: 'chatRoot', params: {conversationIDKey}}]) + } + // Phone: chatRoot + chatConversation on stack + return makeTabState(Tabs.chatTab, [ + {name: 'chatRoot'}, + {name: 'chatConversation', params: {conversationIDKey}}, + ]) +} + +// Build state for a modal screen at root level +const makeModalState = (modalName: string, params?: Record): PartialNavState => ({ + index: 1, + routes: [{name: 'loggedIn'}, {name: modalName, ...(params ? {params} : {})}], +}) + +// ---- URL pattern handling ---- + +// Check if a URL would produce navigation state from our getStateFromPath +export const isHandledByLinkingConfig = (url: string): boolean => { + const prefix = 'keybase://' + if (!url.startsWith(prefix)) return false + return customGetStateFromPath(url.substring(prefix.length)) !== undefined +} + +// Custom getStateFromPath - handles keybase:// URL paths +const customGetStateFromPath = ( + path: string, + _options?: object +): PartialNavState | undefined => { + // path has prefix already stripped by React Navigation (e.g., "convid/abc123") + const cleanPath = path.replace(/^\/+/, '').replace(/\?.*$/, '') + if (!cleanPath) return undefined + + const parts = cleanPath.split('/') + const root = parts[0] + + switch (root) { + // keybase://convid/{conversationIDKey} + case 'convid': + if (parts[1]) { + return makeChatConversationState(parts[1]) + } + break + + // keybase://profile/show/{username} + case 'profile': + if (parts[1] === 'show' && parts[2]) { + return makeTabState(Tabs.peopleTab, [ + {name: 'peopleRoot'}, + {name: 'profile', params: {username: parts[2]}}, + ]) + } + // profile/new-proof is handled by handleAppLink fallback for now + break + + // KBFS paths: keybase://private/..., keybase://public/... + case 'private': + case 'public': { + try { + const decoded = decodeURIComponent(cleanPath) + return makeTabState(Tabs.fsTab, [{name: 'fsRoot', params: {path: `/keybase/${decoded}`}}]) + } catch {} + break + } + + // keybase://incoming-share + case 'incoming-share': + return makeModalState('incomingShareNew') + + // keybase://settingsPushPrompt + case 'settingsPushPrompt': + return makeModalState('settingsPushPrompt') + + // Tab switches: keybase://tabs.chatTab, etc. + case Tabs.chatTab: + case Tabs.peopleTab: + case Tabs.teamsTab: + case Tabs.fsTab: + case Tabs.settingsTab: + case Tabs.cryptoTab: + case Tabs.devicesTab: + case Tabs.gitTab: + return makeTabState(root) + + default: + break + } + + return undefined +} + +// ---- Deep link emission ---- + +// Listener for programmatic deep link emission (e.g., from desktop IPC, engine events) +let _deepLinkListener: ((url: string) => void) | undefined + +// Emit a deep link URL from non-Linking sources (desktop IPC, engine notifications, etc.) +// The URL will be processed through the linking config's getStateFromPath. +export const emitDeepLink = (url: string) => { + const normalized = normalizeUrl(url) + if (normalized) { + _deepLinkListener?.(normalized) + } +} + +// ---- Linking config ---- + +export const createLinkingConfig = ( + handleAppLink: (link: string) => void +): LinkingOptions => ({ + getInitialURL: async () => { + // Compute the startup URL from saved state, push notifications, and deep links. + // This replaces the manual NavigationState construction that was in useInitialState. + const {loggedIn, startup, androidShare} = useConfigState.getState() + if (!loggedIn) return null + + const {tab: startupTab, followUser: startupFollowUser} = startup + let startupConversation = startup.conversation + if (!isValidConversationIDKey(startupConversation)) { + startupConversation = '' + } + + const pushState = usePushState.getState() + const showMonster = + !pushState.justSignedUp && pushState.showPushPrompt && !pushState.hasPermissions + + // Check for an incoming deep link URL (native only) + let deepLinkUrl: string | null = null + if (isMobile) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const RN: {Linking: {getInitialURL: () => Promise}} = require('react-native') + deepLinkUrl = await RN.Linking.getInitialURL() + } catch {} + } + + const haveSavedTab = !!(startupTab || startupConversation) + + // Deep link URL takes priority + if (deepLinkUrl) { + const normalized = normalizeUrl(deepLinkUrl) + if (normalized) { + if (isHandledByLinkingConfig(normalized)) return normalized + // URL not handled by linking config; use imperative navigation as fallback + setTimeout(() => handleAppLink(normalized), 1) + return null + } + } + + // Push permission prompt (if no saved state to restore) + if (showMonster && !haveSavedTab) { + return 'keybase://settingsPushPrompt' + } + + // Android share intent (if no saved state to restore) + if (androidShare && !haveSavedTab) { + return 'keybase://incoming-share' + } + + // Push notification follow user + if (startupFollowUser && !startupConversation) { + return `keybase://profile/show/${startupFollowUser}` + } + + // Saved conversation from last session + if (startupConversation) { + return `keybase://convid/${startupConversation}` + } + + // Saved tab from last session + if (startupTab) { + return `keybase://${startupTab}` + } + + return null + }, + + getStateFromPath: customGetStateFromPath as LinkingOptions['getStateFromPath'], + + prefixes: ['keybase://'], + + subscribe: (listener: (url: string) => void) => { + // Set up the programmatic deep link listener + _deepLinkListener = (url: string) => { + if (isHandledByLinkingConfig(url)) { + listener(url) + } else { + handleAppLink(url) + } + } + + // On native, listen for RN Linking 'url' events (warm-start deep links) + let removeLinkingSub: (() => void) | undefined + if (isMobile) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const RN: {Linking: {addEventListener: (type: string, handler: (e: {url: string}) => void) => {remove: () => void}}} = require('react-native') + const {Linking} = RN + const sub = Linking.addEventListener('url', ({url}: {url: string}) => { + const normalized = normalizeUrl(url) + if (!normalized) return + if (isHandledByLinkingConfig(normalized)) { + listener(normalized) + } else { + handleAppLink(normalized) + } + }) + removeLinkingSub = () => sub.remove() + } catch {} + } + + return () => { + _deepLinkListener = undefined + removeLinkingSub?.() + } + }, +}) diff --git a/shared/router-v2/router.desktop.tsx b/shared/router-v2/router.desktop.tsx index 51816647e878..9e3d17367362 100644 --- a/shared/router-v2/router.desktop.tsx +++ b/shared/router-v2/router.desktop.tsx @@ -12,6 +12,8 @@ import {HeaderLeftCancel} from '@/common-adapters/header-hoc' import {NavigationContainer} from '@react-navigation/native' import {createLeftTabNavigator} from './left-tab-navigator.desktop' import {createNativeStackNavigator} from '@react-navigation/native-stack' +import {createLinkingConfig} from './linking' +import {handleAppLink} from '@/constants/deeplinks' import {modalRoutes, routes, loggedOutRoutes, tabRoots} from './routes' import {registerDebugClear} from '@/util/debug' import type {RootParamList} from '@/router-v2/route-params' @@ -114,6 +116,8 @@ const useConnectNavToState = () => { }, [setNavOnce]) } +const linkingConfig = createLinkingConfig(handleAppLink) + const modalScreens = makeNavScreens(modalRoutes, RootStack.Screen, true, false) const ElectronApp = React.memo(function ElectronApp() { useConnectNavToState() @@ -144,11 +148,12 @@ const ElectronApp = React.memo(function ElectronApp() { return ( {!loggedInLoaded && ( diff --git a/shared/router-v2/router.native.tsx b/shared/router-v2/router.native.tsx index 8fa7aa743aaf..beed6d7d12e3 100644 --- a/shared/router-v2/router.native.tsx +++ b/shared/router-v2/router.native.tsx @@ -16,8 +16,10 @@ import {NavigationContainer, getFocusedRouteNameFromRoute} from '@react-navigati import {createBottomTabNavigator, type BottomTabBarButtonProps} from '@react-navigation/bottom-tabs' import {modalRoutes, routes, loggedOutRoutes, tabRoots} from './routes' import {createNativeStackNavigator} from '@react-navigation/native-stack' -import * as Hooks from './hooks.native' +import {useRootKey} from './hooks.native' import * as TabBar from './tab-bar.native' +import {createLinkingConfig} from './linking' +import {handleAppLink} from '@/constants/deeplinks' import type {RootParamList} from '@/router-v2/route-params' import {useColorScheme} from 'react-native' import {useDaemonState} from '@/stores/daemon' @@ -146,6 +148,10 @@ const modalScreenOptions = { headerLeft: () => , presentation: 'modal', } as const +// Create once, stable across renders. handleAppLink is used as fallback for +// URL patterns not yet handled by the linking config. +const linkingConfig = createLinkingConfig(handleAppLink) + const RNApp = React.memo(function RNApp() { const everLoadedRef = React.useRef(false) const loggedInLoaded = useDaemonState(s => { @@ -154,8 +160,9 @@ const RNApp = React.memo(function RNApp() { return loaded }) - const {initialState, initialStateState} = Hooks.useInitialState(loggedInLoaded) - const loggedIn = useConfigState(s => s.loggedIn) + const {loggedIn, startupLoaded} = useConfigState( + C.useShallow(s => ({loggedIn: s.loggedIn, startupLoaded: s.startup.loaded})) + ) const setNavState = C.useRouterState(s => s.dispatch.setNavState) const onStateChange = React.useCallback(() => { const ns = C.Router2.getRootState() @@ -172,25 +179,14 @@ const RNApp = React.memo(function RNApp() { } }, []) - const DEBUG_RNAPP_RENDER = __DEV__ && (false as boolean) - if (DEBUG_RNAPP_RENDER) { - console.log('DEBUG RNApp render', { - initialState, - initialStateState, - loggedIn, - loggedInLoaded, - onStateChange, - }) - } - const isDarkMode = useColorScheme() === 'dark' const barStyle = useDarkModeState(s => { return s.darkModePreference === 'system' ? 'default' : isDarkMode ? 'light-content' : 'dark-content' }) const bar = barStyle === 'default' ? null : - const rootKey = Hooks.useRootKey() + const rootKey = useRootKey() - if (initialStateState !== 'loaded' || !loggedInLoaded) { + if (!loggedInLoaded || (loggedIn && !startupLoaded)) { return ( @@ -203,12 +199,11 @@ const RNApp = React.memo(function RNApp() { {bar} } + linking={loggedIn ? linkingConfig : undefined} + onStateChange={onStateChange} + onUnhandledAction={onUnhandledAction} ref={navRef} theme={Shared.theme} - // eslint-disable-next-line - initialState={initialState as any} - onUnhandledAction={onUnhandledAction} - onStateChange={onStateChange} > {loggedIn ? ( diff --git a/shared/stores/push.d.ts b/shared/stores/push.d.ts index 29e8b1ff366f..e73902fd1183 100644 --- a/shared/stores/push.d.ts +++ b/shared/stores/push.d.ts @@ -12,12 +12,6 @@ export type State = Store & { dispatch: { defer: { onGetDaemonHandshakeState?: () => T.Config.DaemonHandshakeState - onNavigateToThread?: ( - conversationIDKey: T.Chat.ConversationIDKey, - reason: 'push' | 'extension', - pushBody?: string - ) => void - onShowUserProfile?: (username: string) => void } checkPermissions: () => Promise deleteToken: (version: number) => void diff --git a/shared/stores/push.desktop.tsx b/shared/stores/push.desktop.tsx index 4a59a43a8a35..ef8603fb6c74 100644 --- a/shared/stores/push.desktop.tsx +++ b/shared/stores/push.desktop.tsx @@ -19,8 +19,6 @@ export const usePushState = Z.createZustand(() => { onGetDaemonHandshakeState: () => { return 'done' }, - onNavigateToThread: () => {}, - onShowUserProfile: () => {}, }, deleteToken: () => {}, handlePush: () => {}, diff --git a/shared/stores/push.native.tsx b/shared/stores/push.native.tsx index ca41822e1a2f..0c71ff07ee67 100644 --- a/shared/stores/push.native.tsx +++ b/shared/stores/push.native.tsx @@ -1,7 +1,6 @@ -import * as Tabs from '@/constants/tabs' import * as S from '@/constants/strings' import {ignorePromise, neverThrowPromiseFunc, timeoutPromise} from '@/constants/utils' -import {navigateAppend, navUpToScreen, switchTab} from '@/constants/router' +import {emitDeepLink} from '@/router-v2/linking' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useLogoutState} from '@/stores/logout' @@ -72,7 +71,7 @@ export const usePushState = Z.createZustand((set, get) => { const {conversationIDKey, unboxPayload, membersType} = notification - get().dispatch.defer.onNavigateToThread?.(conversationIDKey, 'push', unboxPayload) + emitDeepLink(`keybase://convid/${conversationIDKey}`) if (unboxPayload && membersType && !isIOS) { try { await T.RPCChat.localUnboxMobilePushNotificationRpcPromise({ @@ -119,12 +118,6 @@ export const usePushState = Z.createZustand((set, get) => { onGetDaemonHandshakeState: () => { throw new Error('onGetDaemonHandshakeState not implemented') }, - onNavigateToThread: () => { - throw new Error('onNavigateToThread not implemented') - }, - onShowUserProfile: () => { - throw new Error('onShowUserProfile not implemented') - }, }, deleteToken: version => { const f = async () => { @@ -170,19 +163,18 @@ export const usePushState = Z.createZustand((set, get) => { // We only care if the user clicked while in session if (notification.userInteraction) { const {username} = notification - get().dispatch.defer.onShowUserProfile?.(username) + emitDeepLink(`keybase://profile/show/${username}`) } break case 'chat.extension': { const {conversationIDKey} = notification - get().dispatch.defer.onNavigateToThread?.(conversationIDKey, 'extension') + emitDeepLink(`keybase://convid/${conversationIDKey}`) } break case 'settings.contacts': if (useConfigState.getState().loggedIn) { - switchTab(Tabs.peopleTab) - navUpToScreen('peopleRoot') + emitDeepLink('keybase://people') } break } @@ -324,8 +316,7 @@ export const usePushState = Z.createZustand((set, get) => { ) { logger.info('[ShowMonsterPushPrompt] Entered through the late permissions checker scenario') await timeoutPromise(100) - switchTab(Tabs.peopleTab) - navigateAppend('settingsPushPrompt') + emitDeepLink('keybase://settingsPushPrompt') } } ignorePromise(monsterPrompt()) From e8a7f737dcd25396977b8466f38d359c23b3c11b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 23 Feb 2026 12:22:56 -0500 Subject: [PATCH 02/12] WIP --- shared/constants/deeplinks.tsx | 32 +++++++++++++++++++++++++++----- shared/router-v2/linking.tsx | 27 +++++++++++++++++++++------ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/shared/constants/deeplinks.tsx b/shared/constants/deeplinks.tsx index c749ea26b62e..99ef15a4d516 100644 --- a/shared/constants/deeplinks.tsx +++ b/shared/constants/deeplinks.tsx @@ -1,6 +1,7 @@ import logger from '@/logger' import * as T from '@/constants/types' -import {navigateAppend} from './router' +import {navigateAppend, navToThread, switchTab} from './router' +import * as Tabs from './tabs' import {useChatState} from '@/stores/chat' import {useProfileState} from '@/stores/profile' import {useTeamsState} from '@/stores/teams' @@ -50,29 +51,50 @@ export const handleAppLink = (link: string) => { } } -// Handle keybase:// URL patterns that the linking config doesn't handle declaratively. -// Patterns handled by the linking config (convid, profile/show, private, public, -// incoming-share, settingsPushPrompt, tab switches) don't reach this function. +// Handle keybase:// URL patterns imperatively. +// Called as fallback for patterns not handled by the linking config's getStateFromPath, +// and as a safety net before the linking subscription is active. const handleKeybaseLink = (link: string) => { if (!link) return const error = "We couldn't read this link. The link might be bad, or your Keybase app might be out of date and needs to be updated." const parts = link.split('/') switch (parts[0]) { + case 'convid': + if (parts[1]) { + navToThread(parts[1] as T.Chat.ConversationIDKey) + return + } + break case 'profile': + if (parts[1] === 'show' && parts[2]) { + useProfileState.getState().dispatch.showUserProfile(parts[2]) + return + } if (parts[1] === 'new-proof' && (parts.length === 3 || parts.length === 4)) { parts.length === 4 && parts[3] && useProfileState.getState().dispatch.showUserProfile(parts[3]) useProfileState.getState().dispatch.addProof(parts[2]!, 'appLink') return } break + case 'private': + case 'public': + try { + const decoded = decodeURIComponent(link) + switchTab(Tabs.fsTab) + navigateAppend({props: {path: `/keybase/${decoded}`}, selected: 'fsRoot'}) + return + } catch { + logger.warn("Couldn't decode KBFS URI") + return + } case 'team': try { const decoded = decodeURIComponent(link) navigateAppend({props: {path: `/keybase/${decoded}`}, selected: 'fsRoot'}) return } catch { - logger.warn("Coudn't decode KBFS URI") + logger.warn("Couldn't decode KBFS URI") return } case 'chat': diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index 1f4fe59fd5c2..6e241109b6ec 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -188,12 +188,20 @@ const customGetStateFromPath = ( // Listener for programmatic deep link emission (e.g., from desktop IPC, engine events) let _deepLinkListener: ((url: string) => void) | undefined +// Fallback handler for when no linking subscription is active (desktop). +// Set by createLinkingConfig. +let _fallbackHandler: ((link: string) => void) | undefined + // Emit a deep link URL from non-Linking sources (desktop IPC, engine notifications, etc.) -// The URL will be processed through the linking config's getStateFromPath. +// On native (with linking config), routes through the linking subscription. +// On desktop (no linking config), falls back to handleAppLink. export const emitDeepLink = (url: string) => { const normalized = normalizeUrl(url) - if (normalized) { - _deepLinkListener?.(normalized) + if (!normalized) return + if (_deepLinkListener) { + _deepLinkListener(normalized) + } else { + _fallbackHandler?.(normalized) } } @@ -201,8 +209,10 @@ export const emitDeepLink = (url: string) => { export const createLinkingConfig = ( handleAppLink: (link: string) => void -): LinkingOptions => ({ - getInitialURL: async () => { +): LinkingOptions => { + _fallbackHandler = handleAppLink + return { + getInitialURL: async () => { // Compute the startup URL from saved state, push notifications, and deep links. // This replaces the manual NavigationState construction that was in useInitialState. const {loggedIn, startup, androidShare} = useConfigState.getState() @@ -269,6 +279,10 @@ export const createLinkingConfig = ( return null }, + // Prevent React Navigation from updating window.location on Electron (file:// protocol). + // On native this is a no-op since there's no browser URL to update. + getPathFromState: () => '', + getStateFromPath: customGetStateFromPath as LinkingOptions['getStateFromPath'], prefixes: ['keybase://'], @@ -308,4 +322,5 @@ export const createLinkingConfig = ( removeLinkingSub?.() } }, -}) + } +} From a3af57018b343fa64ef0b3175451eedded4c8ecc Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 23 Feb 2026 16:41:27 -0500 Subject: [PATCH 03/12] WIP --- shared/constants/router.tsx | 20 +++++++++++++++----- shared/router-v2/router.desktop.tsx | 4 ++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index 87ccc7dab665..9da8902e76e9 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -12,6 +12,7 @@ import { import type {NavigateAppendType, RouteKeys, RootParamList as KBRootParamList} from '@/router-v2/route-params' import type {GetOptionsRet} from './types/router' import {makeChatConversationState} from '@/router-v2/linking' +import {isSplit} from './chat/common' import {isMobile} from './platform' import {shallowEqual, type ViewPropsToPageProps} from './utils' import {registerDebugClear} from '@/util/debug' @@ -258,11 +259,20 @@ export const navToThread = (conversationIDKey: T.Chat.ConversationIDKey) => { const rs = getRootState() if (!rs?.key) return - const nextState = makeChatConversationState(conversationIDKey) - n.dispatch({ - ...CommonActions.reset(nextState as Parameters[0]), - target: rs.key, - }) + if (isSplit) { + // Desktop/tablet split view: switch to chat tab and update chatRoot params. + // navigateAppend with replace uses setParams when the screen is already visible, + // which avoids remounting the navigator tree. + switchTab('chatTab' as Tabs.AppTab) + navigateAppend({props: {conversationIDKey}, selected: 'chatRoot'}, true) + } else { + // Phone: full reset to build the chat → conversation stack + const nextState = makeChatConversationState(conversationIDKey) + n.dispatch({ + ...CommonActions.reset(nextState as Parameters[0]), + target: rs.key, + }) + } } export const appendPeopleBuilder = () => { diff --git a/shared/router-v2/router.desktop.tsx b/shared/router-v2/router.desktop.tsx index 9e3d17367362..6af3a3c438f3 100644 --- a/shared/router-v2/router.desktop.tsx +++ b/shared/router-v2/router.desktop.tsx @@ -116,7 +116,8 @@ const useConnectNavToState = () => { }, [setNavOnce]) } -const linkingConfig = createLinkingConfig(handleAppLink) +// Set up the fallback handler for emitDeepLink on desktop (no linking prop needed on Electron) +createLinkingConfig(handleAppLink) const modalScreens = makeNavScreens(modalRoutes, RootStack.Screen, true, false) const ElectronApp = React.memo(function ElectronApp() { @@ -149,7 +150,6 @@ const ElectronApp = React.memo(function ElectronApp() { return ( Date: Mon, 23 Feb 2026 17:16:19 -0500 Subject: [PATCH 04/12] WIP --- shared/router-v2/router.native.tsx | 5 +++++ shared/stores/chat.tsx | 11 ++++++++--- shared/stores/convostate.tsx | 4 ++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/shared/router-v2/router.native.tsx b/shared/router-v2/router.native.tsx index beed6d7d12e3..3da9dd979c35 100644 --- a/shared/router-v2/router.native.tsx +++ b/shared/router-v2/router.native.tsx @@ -168,6 +168,10 @@ const RNApp = React.memo(function RNApp() { const ns = C.Router2.getRootState() setNavState(ns) }, [setNavState]) + // Sync the initial state from the linking config into the router store. + // onStateChange doesn't fire for the initial state, so this ensures + // onRouteChanged runs and conversation data gets loaded on startup. + const onReady = onStateChange const onUnhandledAction = React.useCallback((a: Readonly<{type: string}>) => { logger.info(`[NAV] Unhandled action: ${a.type}`, a, C.Router2.logState()) @@ -200,6 +204,7 @@ const RNApp = React.memo(function RNApp() { } linking={loggedIn ? linkingConfig : undefined} + onReady={onReady} onStateChange={onStateChange} onUnhandledAction={onUnhandledAction} ref={navRef} diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 39eca6a74bb7..2b4b8864d457 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -992,10 +992,15 @@ export const useChatState = Z.createZustand((set, get) => { navigateToInbox: (allowSwitchTab = true) => { // components can call us during render sometimes so always defer setTimeout(() => { - navUpToScreen('chatRoot') - if (allowSwitchTab) { - switchTab(Tabs.chatTab) + if (getTab() !== Tabs.chatTab) { + // Not on chat tab — switching lands on chatRoot (the tab's initial route). + // Don't call navUpToScreen which would push chatRoot onto the wrong tab's stack. + if (allowSwitchTab) { + switchTab(Tabs.chatTab) + } + return } + navUpToScreen('chatRoot') }, 1) }, onChatInboxSynced: action => { diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 2732da9d8a11..2374eb772a69 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -2605,6 +2605,10 @@ const createSlice = (): Z.ImmerStateCreator => (set, get) => { }) fetchConversationBio() get().dispatch.defer.chatResetConversationErrored() + // Load messages if not already loaded (e.g., screen rendered via linking state restoration) + if (!get().loaded) { + get().dispatch.loadMoreMessages({forceClear: true, reason: 'focused'}) + } }, sendAudioRecording: async (path, duration, amps) => { const outboxID = Common.generateOutboxID() From d9dd0846eb2d36f4c01f58199a17801934353ef3 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 24 Feb 2026 10:02:24 -0500 Subject: [PATCH 05/12] WIP --- shared/router-v2/linking.tsx | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index 6e241109b6ec..efab46803a38 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -192,12 +192,22 @@ let _deepLinkListener: ((url: string) => void) | undefined // Set by createLinkingConfig. let _fallbackHandler: ((link: string) => void) | undefined +// URL returned by getInitialURL, used to deduplicate cold-start push notifications. +// On cold start from a push tap, the same notification is processed by both +// getInitialURL (via startupConversation) and the push listener (via handlePush). +// This prevents the second navigation. +let _initialURLOnce: string | undefined + // Emit a deep link URL from non-Linking sources (desktop IPC, engine notifications, etc.) // On native (with linking config), routes through the linking subscription. // On desktop (no linking config), falls back to handleAppLink. export const emitDeepLink = (url: string) => { const normalized = normalizeUrl(url) if (!normalized) return + if (_initialURLOnce && _initialURLOnce === normalized) { + _initialURLOnce = undefined + return + } if (_deepLinkListener) { _deepLinkListener(normalized) } else { @@ -263,12 +273,14 @@ export const createLinkingConfig = ( // Push notification follow user if (startupFollowUser && !startupConversation) { - return `keybase://profile/show/${startupFollowUser}` + _initialURLOnce = `keybase://profile/show/${startupFollowUser}` + return _initialURLOnce } // Saved conversation from last session if (startupConversation) { - return `keybase://convid/${startupConversation}` + _initialURLOnce = `keybase://convid/${startupConversation}` + return _initialURLOnce } // Saved tab from last session @@ -288,10 +300,22 @@ export const createLinkingConfig = ( prefixes: ['keybase://'], subscribe: (listener: (url: string) => void) => { + // Deduplicate rapid calls to listener from multiple sources (e.g., push handler + // via emitDeepLink AND RN Linking 'url' event both firing for the same push tap). + let _lastUrl: string | undefined + let _lastTime = 0 + const dedupedListener = (url: string) => { + const now = Date.now() + if (url === _lastUrl && now - _lastTime < 1500) return + _lastUrl = url + _lastTime = now + listener(url) + } + // Set up the programmatic deep link listener _deepLinkListener = (url: string) => { if (isHandledByLinkingConfig(url)) { - listener(url) + dedupedListener(url) } else { handleAppLink(url) } @@ -308,7 +332,7 @@ export const createLinkingConfig = ( const normalized = normalizeUrl(url) if (!normalized) return if (isHandledByLinkingConfig(normalized)) { - listener(normalized) + dedupedListener(normalized) } else { handleAppLink(normalized) } From 1d093e22cc3d7649bdb4286efebecf5b65a49727 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 24 Feb 2026 16:25:38 -0500 Subject: [PATCH 06/12] WIP --- shared/constants/deeplinks.tsx | 1 + shared/router-v2/linking.tsx | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/shared/constants/deeplinks.tsx b/shared/constants/deeplinks.tsx index 99ef15a4d516..16cd93e8945e 100644 --- a/shared/constants/deeplinks.tsx +++ b/shared/constants/deeplinks.tsx @@ -68,6 +68,7 @@ const handleKeybaseLink = (link: string) => { break case 'profile': if (parts[1] === 'show' && parts[2]) { + switchTab(Tabs.peopleTab) useProfileState.getState().dispatch.showUserProfile(parts[2]) return } diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index efab46803a38..c18046d8ee90 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -314,6 +314,12 @@ export const createLinkingConfig = ( // Set up the programmatic deep link listener _deepLinkListener = (url: string) => { + // Profile deep links need imperative navigation to properly set up + // the back stack. State-based linking may not create intermediate screens. + if (url.startsWith('keybase://profile/')) { + handleAppLink(url) + return + } if (isHandledByLinkingConfig(url)) { dedupedListener(url) } else { @@ -331,6 +337,12 @@ export const createLinkingConfig = ( const sub = Linking.addEventListener('url', ({url}: {url: string}) => { const normalized = normalizeUrl(url) if (!normalized) return + // Profile deep links need imperative navigation to properly set up + // the back stack. State-based linking may not create intermediate screens. + if (normalized.startsWith('keybase://profile/')) { + handleAppLink(normalized) + return + } if (isHandledByLinkingConfig(normalized)) { dedupedListener(normalized) } else { From 1a15039a7f60f4f426a4515fabaad3ebb5fd5aac Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 24 Feb 2026 18:22:57 -0500 Subject: [PATCH 07/12] fix device timeline --- shared/devices/device-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/devices/device-page.tsx b/shared/devices/device-page.tsx index adfe805f391c..b48937e9d0d3 100644 --- a/shared/devices/device-page.tsx +++ b/shared/devices/device-page.tsx @@ -10,7 +10,7 @@ type OwnProps = {deviceID: string} const TimelineMarker = (p: {first: boolean; last: boolean; closedCircle: boolean}) => { const {first, last, closedCircle} = p return ( - + From 8f8c30988d9f868ebcacbe44d8b9a09c9484205e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 24 Feb 2026 18:43:11 -0500 Subject: [PATCH 08/12] WIP --- shared/android/app/build.gradle | 7 +++---- .../ossifrage/KeybasePushNotificationListenerService.kt | 5 ----- shared/android/build.gradle | 2 +- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/shared/android/app/build.gradle b/shared/android/app/build.gradle index 29e9bfaf1434..647e55accb76 100644 --- a/shared/android/app/build.gradle +++ b/shared/android/app/build.gradle @@ -170,16 +170,15 @@ dependencies { implementation jscFlavor } - implementation 'androidx.work:work-runtime:2.10.5' + implementation 'androidx.work:work-runtime:2.11.1' implementation 'androidx.multidex:multidex:2.0.1' implementation "com.google.firebase:firebase-messaging:25.0.1" implementation "com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}" implementation 'org.msgpack:msgpack-core:0.9.10' implementation project(':keybaselib') implementation 'com.android.installreferrer:installreferrer:2.2' - implementation "me.leolin:ShortcutBadger:1.1.22@aar" - implementation "androidx.lifecycle:lifecycle-common-java8:2.9.4" - implementation "androidx.lifecycle:lifecycle-process:2.9.4" + implementation "androidx.lifecycle:lifecycle-common-java8:2.10.0" + implementation "androidx.lifecycle:lifecycle-process:2.10.0" } // This requires a google-services.json file locally. Drop it in diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt index 7b7146a92953..ec0a2d5e9398 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt @@ -17,7 +17,6 @@ import io.keybase.ossifrage.MainActivity.Companion.setupKBRuntime import io.keybase.ossifrage.modules.NativeLogger import keybase.Keybase import keybase.ChatNotification -import me.leolin.shortcutbadger.ShortcutBadger import com.reactnativekb.KbModule import org.json.JSONArray import org.json.JSONObject @@ -67,10 +66,6 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { if (!bundle.containsKey("color")) { bundle.putString("color", data.optString("color", "")) } - val badge = data.optInt("badge", -1) - if (badge >= 0) { - ShortcutBadger.applyCount(this, badge) - } } try { val type = bundle.getString("type") diff --git a/shared/android/build.gradle b/shared/android/build.gradle index 8cd3c9b909e9..a322cb3cfcea 100644 --- a/shared/android/build.gradle +++ b/shared/android/build.gradle @@ -18,7 +18,7 @@ buildscript { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") classpath("com.github.triplet.gradle:play-publisher:3.7.0") // To publish from gradle - classpath("com.google.gms:google-services:4.4.0") + classpath("com.google.gms:google-services:4.4.4") } } From e493d62fc876cb8a77a8e518cddada4ad356f72f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 24 Feb 2026 19:13:17 -0500 Subject: [PATCH 09/12] WIP --- shared/constants/init/push-listener.native.tsx | 12 ++++++++++++ shared/router-v2/linking.tsx | 5 ++++- shared/stores/push.native.tsx | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index 35fcf178f0bf..122dae3977af 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -12,6 +12,7 @@ import { shareListenersRegistered, } from 'react-native-kb' import {useConfigState} from '@/stores/config' +import {useCurrentUserState} from '@/stores/current-user' import {useLogoutState} from '@/stores/logout' import {usePushState} from '@/stores/push' @@ -199,6 +200,17 @@ export const initPushListener = () => { lastCount = count }) + // Retry token upload when user state becomes available. + // The FCM token often arrives before username/deviceID are loaded, + // so the initial upload silently bails. This retries once user state is ready. + useCurrentUserState.subscribe((s, old) => { + if (s.username === old.username && s.deviceID === old.deviceID) return + const token = usePushState.getState().token + if (token && s.username && s.deviceID) { + usePushState.getState().dispatch.setPushToken(token) + } + }) + usePushState.getState().dispatch.initialPermissionsCheck() const listenNative = async () => { diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index c18046d8ee90..04b8154f3bdb 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -3,7 +3,7 @@ import {isSplit} from '@/constants/chat/common' import {isValidConversationIDKey} from '@/constants/types/chat/common' import {isMobile} from '@/constants/platform' import {useConfigState} from '@/stores/config' -import {usePushState} from '@/stores/push' +import type * as UsePushStateType from '@/stores/push' import type {LinkingOptions} from '@react-navigation/native' import type {RootParamList} from './route-params' @@ -234,6 +234,9 @@ export const createLinkingConfig = ( startupConversation = '' } + // Lazy-require to break require cycle: linking.tsx → push.native.tsx → linking.tsx + // eslint-disable-next-line @typescript-eslint/no-var-requires + const {usePushState} = require('@/stores/push') as typeof UsePushStateType const pushState = usePushState.getState() const showMonster = !pushState.justSignedUp && pushState.showPushPrompt && !pushState.hasPermissions diff --git a/shared/stores/push.native.tsx b/shared/stores/push.native.tsx index 0c71ff07ee67..10bf1f98cea1 100644 --- a/shared/stores/push.native.tsx +++ b/shared/stores/push.native.tsx @@ -275,6 +275,7 @@ export const usePushState = Z.createZustand((set, get) => { const uploadPushToken = async () => { const {deviceID, username} = useCurrentUserState.getState() if (!username || !deviceID) { + logger.info('[PushToken] skipping upload, no user state yet') return } try { From 0c6165e09e663804d27eedcd3adaf44f668c5cb8 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 24 Feb 2026 19:47:57 -0500 Subject: [PATCH 10/12] WIP --- shared/constants/chat/common.tsx | 6 ++---- shared/constants/chat/layout.tsx | 5 +++++ shared/constants/router.tsx | 14 ++++++++++---- shared/desktop/renderer/main2.desktop.tsx | 14 +++++++++++--- shared/router-v2/linking.tsx | 2 +- shared/stores/archive.tsx | 2 +- shared/stores/autoreset.tsx | 2 +- shared/stores/bots.tsx | 2 +- shared/stores/chat.tsx | 2 +- shared/stores/config.tsx | 2 +- shared/stores/crypto.tsx | 2 +- shared/stores/current-user.tsx | 2 +- shared/stores/daemon.tsx | 2 +- shared/stores/darkmode.tsx | 2 +- shared/stores/devices.tsx | 2 +- shared/stores/followers.tsx | 2 +- shared/stores/fs.tsx | 2 +- shared/stores/git.tsx | 2 +- shared/stores/logout.tsx | 2 +- shared/stores/notifications.tsx | 2 +- shared/stores/people.tsx | 2 +- shared/stores/pinentry.tsx | 2 +- shared/stores/profile.tsx | 2 +- shared/stores/provision.tsx | 2 +- shared/stores/push.desktop.tsx | 2 +- shared/stores/push.native.tsx | 2 +- shared/stores/recover-password.tsx | 2 +- shared/stores/router.tsx | 2 +- shared/stores/settings-chat.tsx | 2 +- shared/stores/settings-contacts.desktop.tsx | 2 +- shared/stores/settings-contacts.native.tsx | 2 +- shared/stores/settings-email.tsx | 2 +- shared/stores/settings-notifications.tsx | 2 +- shared/stores/settings-password.tsx | 2 +- shared/stores/settings-phone.tsx | 2 +- shared/stores/settings.tsx | 2 +- shared/stores/signup.tsx | 2 +- shared/stores/teams.tsx | 2 +- shared/stores/tracker.tsx | 2 +- shared/stores/unlock-folders.tsx | 2 +- shared/stores/users.tsx | 2 +- shared/stores/waiting.tsx | 2 +- shared/stores/wallets.tsx | 2 +- shared/stores/whats-new.tsx | 2 +- shared/util/zustand.tsx | 21 ++++++++++++++++++++- 45 files changed, 88 insertions(+), 52 deletions(-) create mode 100644 shared/constants/chat/layout.tsx diff --git a/shared/constants/chat/common.tsx b/shared/constants/chat/common.tsx index b39d758adf12..c6ab33d2cd33 100644 --- a/shared/constants/chat/common.tsx +++ b/shared/constants/chat/common.tsx @@ -1,7 +1,7 @@ import * as T from '../types' -import {isMobile, isTablet} from '../platform' import {getVisibleScreen} from '@/constants/router' import {useConfigState} from '@/stores/config' +import {isSplit, threadRouteName} from './layout' export const explodingModeGregorKeyPrefix = 'exploding:' @@ -14,9 +14,7 @@ export const getSelectedConversation = (allowUnderModal: boolean = false): T.Cha return T.Chat.noConversationIDKey } -// in split mode the root is the 'inbox' -export const isSplit = !isMobile || isTablet // Whether the inbox and conversation panels are visible side-by-side. -export const threadRouteName = isSplit ? 'chatRoot' : 'chatConversation' +export {isSplit, threadRouteName} from './layout' export const isUserActivelyLookingAtThisThread = (conversationIDKey: T.Chat.ConversationIDKey) => { const selectedConversationIDKey = getSelectedConversation() diff --git a/shared/constants/chat/layout.tsx b/shared/constants/chat/layout.tsx new file mode 100644 index 000000000000..631ac0509a23 --- /dev/null +++ b/shared/constants/chat/layout.tsx @@ -0,0 +1,5 @@ +import {isMobile, isTablet} from '../platform' + +// in split mode the root is the 'inbox' +export const isSplit = !isMobile || isTablet // Whether the inbox and conversation panels are visible side-by-side. +export const threadRouteName = isSplit ? 'chatRoot' : 'chatConversation' diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index 9da8902e76e9..d5ea90245fca 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -1,6 +1,6 @@ import type * as React from 'react' import type * as T from './types' -import type * as Tabs from './tabs' +import * as Tabs from './tabs' import { StackActions, CommonActions, @@ -11,8 +11,7 @@ import { } from '@react-navigation/core' import type {NavigateAppendType, RouteKeys, RootParamList as KBRootParamList} from '@/router-v2/route-params' import type {GetOptionsRet} from './types/router' -import {makeChatConversationState} from '@/router-v2/linking' -import {isSplit} from './chat/common' +import {isSplit} from './chat/layout' import {isMobile} from './platform' import {shallowEqual, type ViewPropsToPageProps} from './utils' import {registerDebugClear} from '@/util/debug' @@ -267,7 +266,14 @@ export const navToThread = (conversationIDKey: T.Chat.ConversationIDKey) => { navigateAppend({props: {conversationIDKey}, selected: 'chatRoot'}, true) } else { // Phone: full reset to build the chat → conversation stack - const nextState = makeChatConversationState(conversationIDKey) + const nextState = { + routes: [{name: 'loggedIn', state: { + routes: [{name: Tabs.chatTab, state: { + index: 1, + routes: [{name: 'chatRoot'}, {name: 'chatConversation', params: {conversationIDKey}}], + }}], + }}], + } n.dispatch({ ...CommonActions.reset(nextState as Parameters[0]), target: rs.key, diff --git a/shared/desktop/renderer/main2.desktop.tsx b/shared/desktop/renderer/main2.desktop.tsx index 58d01aad998d..50969c97e9ba 100644 --- a/shared/desktop/renderer/main2.desktop.tsx +++ b/shared/desktop/renderer/main2.desktop.tsx @@ -127,13 +127,21 @@ const preloadFonts = () => { void document.fonts.load('italic 700 16px "Keybase"') } +// Cache React root across HMR to avoid remounting the entire tree +// eslint-disable-next-line +const _rootRef: {current?: ReactDOM.Root} = (globalThis as any).__hmr_reactRoot ??= {} + const render = (Component = Main) => { - const root = document.getElementById('root') - if (!root) { + const rootEl = document.getElementById('root') + if (!rootEl) { throw new Error('No root element?') } - ReactDOM.createRoot(root).render( + if (!_rootRef.current) { + _rootRef.current = ReactDOM.createRoot(rootEl) + } + + _rootRef.current.render(
diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index 04b8154f3bdb..58f09d45f722 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -1,5 +1,5 @@ import * as Tabs from '@/constants/tabs' -import {isSplit} from '@/constants/chat/common' +import {isSplit} from '@/constants/chat/layout' import {isValidConversationIDKey} from '@/constants/types/chat/common' import {isMobile} from '@/constants/platform' import {useConfigState} from '@/stores/config' diff --git a/shared/stores/archive.tsx b/shared/stores/archive.tsx index 7997cc0ae746..97869e1b635b 100644 --- a/shared/stores/archive.tsx +++ b/shared/stores/archive.tsx @@ -90,7 +90,7 @@ export interface State extends Store { } } -export const useArchiveState = Z.createZustand((set, get) => { +export const useArchiveState = Z.createZustand('archive', (set, get) => { const setKBFSJobStatus = (status: T.RPCGen.SimpleFSArchiveStatus) => { set(s => { s.kbfsJobs = new Map( diff --git a/shared/stores/autoreset.tsx b/shared/stores/autoreset.tsx index 64cffecf6c8c..3cd229384858 100644 --- a/shared/stores/autoreset.tsx +++ b/shared/stores/autoreset.tsx @@ -44,7 +44,7 @@ export interface State extends Store { } } -export const useAutoResetState = Z.createZustand((set, get) => { +export const useAutoResetState = Z.createZustand('autoreset', (set, get) => { const dispatch: State['dispatch'] = { cancelReset: () => { set(s => { diff --git a/shared/stores/bots.tsx b/shared/stores/bots.tsx index e922a4538206..8ea9759b876e 100644 --- a/shared/stores/bots.tsx +++ b/shared/stores/bots.tsx @@ -40,7 +40,7 @@ export interface State extends Store { } const pageSize = 100 -export const useBotsState = Z.createZustand((set, get) => { +export const useBotsState = Z.createZustand('bots', (set, get) => { const dispatch: State['dispatch'] = { getFeaturedBots: (limit, page) => { const f = async () => { diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 870ad047e62f..cd5e9a076378 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -391,7 +391,7 @@ const untrustedConversationIDKeys = (ids: ReadonlyArray storeRegistry.getConvoState(id).meta.trustedState === 'untrusted') // generic chat store -export const useChatState = Z.createZustand((set, get) => { +export const useChatState = Z.createZustand('chat', (set, get) => { // We keep a set of conversations to unbox let metaQueue = new Set() diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index c14a2128de43..c9a91205776d 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -213,7 +213,7 @@ export interface State extends Store { } export const openAtLoginKey = 'openAtLogin' -export const useConfigState = Z.createZustand((set, get) => { +export const useConfigState = Z.createZustand('config', (set, get) => { const nativeFrameKey = 'useNativeFrame' const notifySoundKey = 'notifySound' const forceSmallNavKey = 'ui.forceSmallNav' diff --git a/shared/stores/crypto.tsx b/shared/stores/crypto.tsx index 0946497a4ffc..c48de623e14b 100644 --- a/shared/stores/crypto.tsx +++ b/shared/stores/crypto.tsx @@ -166,7 +166,7 @@ export type State = Store & { } } -export const useCryptoState = Z.createZustand((set, get) => { +export const useCryptoState = Z.createZustand('crypto', (set, get) => { const resetWarnings = (o: CommonStore) => { o.errorMessage = new HiddenString('') o.warningMessage = new HiddenString('') diff --git a/shared/stores/current-user.tsx b/shared/stores/current-user.tsx index aaba7e2cc2cd..355b37d40305 100644 --- a/shared/stores/current-user.tsx +++ b/shared/stores/current-user.tsx @@ -32,7 +32,7 @@ export interface State extends Store { } } -export const useCurrentUserState = Z.createZustand(set => { +export const useCurrentUserState = Z.createZustand('current-user', set => { const dispatch: State['dispatch'] = { replaceUsername: u => { set(s => { diff --git a/shared/stores/daemon.tsx b/shared/stores/daemon.tsx index 340fcd3949e5..2b27c950c916 100644 --- a/shared/stores/daemon.tsx +++ b/shared/stores/daemon.tsx @@ -50,7 +50,7 @@ export interface State extends Store { } } -export const useDaemonState = Z.createZustand((set, get) => { +export const useDaemonState = Z.createZustand('daemon', (set, get) => { const restartHandshake = () => { get().dispatch.onRestartHandshakeNative() get().dispatch.setState('starting') diff --git a/shared/stores/darkmode.tsx b/shared/stores/darkmode.tsx index c616904f1e4e..b92d1159703a 100644 --- a/shared/stores/darkmode.tsx +++ b/shared/stores/darkmode.tsx @@ -34,7 +34,7 @@ const ignorePromise = (f: Promise) => { f.then(() => {}).catch(() => {}) } -export const useDarkModeState = Z.createZustand((set, get) => { +export const useDarkModeState = Z.createZustand('darkmode', (set, get) => { const dispatch: State['dispatch'] = { loadDarkPrefs: () => { const f = async () => { diff --git a/shared/stores/devices.tsx b/shared/stores/devices.tsx index a8404ad69f98..fc1f2f72a593 100644 --- a/shared/stores/devices.tsx +++ b/shared/stores/devices.tsx @@ -21,7 +21,7 @@ export interface State extends T.Devices.State { } } -export const useDevicesState = Z.createZustand((set, get) => { +export const useDevicesState = Z.createZustand('devices', (set, get) => { const dispatch: State['dispatch'] = { clearBadges: () => { ignorePromise(T.RPCGen.deviceDismissDeviceChangeNotificationsRpcPromise()) diff --git a/shared/stores/followers.tsx b/shared/stores/followers.tsx index b403722e07af..04b2a6c91ad3 100644 --- a/shared/stores/followers.tsx +++ b/shared/stores/followers.tsx @@ -18,7 +18,7 @@ export interface State extends Store { updateFollowers: (user: string, add: boolean) => void } } -export const useFollowerState = Z.createZustand(set => { +export const useFollowerState = Z.createZustand('followers', set => { const dispatch: State['dispatch'] = { replace: (followers, following) => { set(s => { diff --git a/shared/stores/fs.tsx b/shared/stores/fs.tsx index 10ebcfcf9453..d6a370d1f299 100644 --- a/shared/stores/fs.tsx +++ b/shared/stores/fs.tsx @@ -560,7 +560,7 @@ const updatePathItem = ( return newPathItemFromAction } -export const useFSState = Z.createZustand((set, get) => { +export const useFSState = Z.createZustand('fs', (set, get) => { // Can't rely on kbfsDaemonStatus.rpcStatus === 'waiting' as that's set by // reducer and happens before this. let waitForKbfsDaemonInProgress = false diff --git a/shared/stores/git.tsx b/shared/stores/git.tsx index ea937bd2ef9c..2d307951bcdd 100644 --- a/shared/stores/git.tsx +++ b/shared/stores/git.tsx @@ -91,7 +91,7 @@ export interface State extends Store { } } -export const useGitState = Z.createZustand((set, get) => { +export const useGitState = Z.createZustand('git', (set, get) => { const callAndHandleError = (f: () => Promise, loadAfter = true) => { const wrapper = async () => { try { diff --git a/shared/stores/logout.tsx b/shared/stores/logout.tsx index a3dc47b95619..890d0b9084b0 100644 --- a/shared/stores/logout.tsx +++ b/shared/stores/logout.tsx @@ -29,7 +29,7 @@ export interface State extends Store { } } -export const useLogoutState = Z.createZustand((set, get) => { +export const useLogoutState = Z.createZustand('logout', (set, get) => { const dispatch: State['dispatch'] = { requestLogout: () => { // Figure out whether we can log out using CanLogout, if so, diff --git a/shared/stores/notifications.tsx b/shared/stores/notifications.tsx index ba83285d4302..59244a6f2a98 100644 --- a/shared/stores/notifications.tsx +++ b/shared/stores/notifications.tsx @@ -83,7 +83,7 @@ const badgeStateToBadgeCounts = (bs: T.RPCGen.BadgeState) => { return counts } -export const useNotifState = Z.createZustand((set, get) => { +export const useNotifState = Z.createZustand('notifications', (set, get) => { const updateWidgetBadge = (s: Z.WritableDraft) => { let widgetBadge: BadgeType = 'regular' const {keyState} = s diff --git a/shared/stores/people.tsx b/shared/stores/people.tsx index 27a6a2746e2d..62b8840580e7 100644 --- a/shared/stores/people.tsx +++ b/shared/stores/people.tsx @@ -371,7 +371,7 @@ export interface State extends Store { } } -export const usePeopleState = Z.createZustand((set, get) => { +export const usePeopleState = Z.createZustand('people', (set, get) => { const dispatch: State['dispatch'] = { dismissAnnouncement: id => { const f = async () => { diff --git a/shared/stores/pinentry.tsx b/shared/stores/pinentry.tsx index 8dc26ad61ace..60cffa216db1 100644 --- a/shared/stores/pinentry.tsx +++ b/shared/stores/pinentry.tsx @@ -43,7 +43,7 @@ export interface State extends Store { } } -export const usePinentryState = Z.createZustand((set, get) => { +export const usePinentryState = Z.createZustand('pinentry', (set, get) => { const dispatch: State['dispatch'] = { dynamic: { onCancel: undefined, diff --git a/shared/stores/profile.tsx b/shared/stores/profile.tsx index e65e3bdac10a..fe1286520d5e 100644 --- a/shared/stores/profile.tsx +++ b/shared/stores/profile.tsx @@ -142,7 +142,7 @@ export interface State extends Store { } } -export const useProfileState = Z.createZustand((set, get) => { +export const useProfileState = Z.createZustand('profile', (set, get) => { const clearErrors = (s: Z.WritableDraft) => { s.errorCode = undefined s.errorText = '' diff --git a/shared/stores/provision.tsx b/shared/stores/provision.tsx index cbe722bd68f6..135b8fd80303 100644 --- a/shared/stores/provision.tsx +++ b/shared/stores/provision.tsx @@ -122,7 +122,7 @@ export interface State extends Store { } } -export const useProvisionState = Z.createZustand((set, get) => { +export const useProvisionState = Z.createZustand('provision', (set, get) => { const _cancel = wrapErrors((ignoreWarning?: boolean) => { useWaitingState.getState().dispatch.clear(waitingKeyProvision) if (!ignoreWarning) { diff --git a/shared/stores/push.desktop.tsx b/shared/stores/push.desktop.tsx index ef8603fb6c74..1d860c6fe283 100644 --- a/shared/stores/push.desktop.tsx +++ b/shared/stores/push.desktop.tsx @@ -10,7 +10,7 @@ const initialStore: Store = { token: '', } -export const usePushState = Z.createZustand(() => { +export const usePushState = Z.createZustand('push', () => { const dispatch: State['dispatch'] = { checkPermissions: async () => { return Promise.resolve(false) diff --git a/shared/stores/push.native.tsx b/shared/stores/push.native.tsx index 10bf1f98cea1..3eec6daa9e97 100644 --- a/shared/stores/push.native.tsx +++ b/shared/stores/push.native.tsx @@ -29,7 +29,7 @@ const initialStore: Store = { } const monsterStorageKey = 'shownMonsterPushPrompt' -export const usePushState = Z.createZustand((set, get) => { +export const usePushState = Z.createZustand('push', (set, get) => { const neverShowMonsterAgain = async () => { await T.RPCGen.configGuiSetValueRpcPromise({ path: `ui.${monsterStorageKey}`, diff --git a/shared/stores/recover-password.tsx b/shared/stores/recover-password.tsx index d754cc053c6c..42d4772be21b 100644 --- a/shared/stores/recover-password.tsx +++ b/shared/stores/recover-password.tsx @@ -50,7 +50,7 @@ export interface State extends Store { } } -export const useState = Z.createZustand((set, get) => { +export const useState = Z.createZustand('recover-password', (set, get) => { const dispatch: State['dispatch'] = { defer: { onProvisionCancel: () => { diff --git a/shared/stores/router.tsx b/shared/stores/router.tsx index 74b7f7717ca6..ee10340ff2b3 100644 --- a/shared/stores/router.tsx +++ b/shared/stores/router.tsx @@ -51,7 +51,7 @@ export interface State extends Store { appendPeopleBuilder: () => void } -export const useRouterState = Z.createZustand((set, get) => { +export const useRouterState = Z.createZustand('router', (set, get) => { const dispatch: State['dispatch'] = { clearModals: Util.clearModals, defer: { diff --git a/shared/stores/settings-chat.tsx b/shared/stores/settings-chat.tsx index 71e6af5aca23..49bb2e60ebe0 100644 --- a/shared/stores/settings-chat.tsx +++ b/shared/stores/settings-chat.tsx @@ -45,7 +45,7 @@ export interface State extends Store { } } -export const useSettingsChatState = Z.createZustand((set, get) => { +export const useSettingsChatState = Z.createZustand('settings-chat', (set, get) => { const dispatch: State['dispatch'] = { contactSettingsRefresh: () => { const f = async () => { diff --git a/shared/stores/settings-contacts.desktop.tsx b/shared/stores/settings-contacts.desktop.tsx index 6dfe2e5514ce..ed8863efbedf 100644 --- a/shared/stores/settings-contacts.desktop.tsx +++ b/shared/stores/settings-contacts.desktop.tsx @@ -11,7 +11,7 @@ const initialStore: Store = { waitingToShowJoinedModal: false, } -export const useSettingsContactsState = Z.createZustand(() => { +export const useSettingsContactsState = Z.createZustand('settings-contacts', () => { const dispatch: State['dispatch'] = { editContactImportEnabled: () => {}, importContactsLater: () => {}, diff --git a/shared/stores/settings-contacts.native.tsx b/shared/stores/settings-contacts.native.tsx index d7da466e42ca..df48b555873e 100644 --- a/shared/stores/settings-contacts.native.tsx +++ b/shared/stores/settings-contacts.native.tsx @@ -73,7 +73,7 @@ const makeContactsResolvedMessage = (cts: T.Immutable((set, get) => { +export const useSettingsContactsState = Z.createZustand('settings-contacts', (set, get) => { const dispatch: State['dispatch'] = { editContactImportEnabled: (enable, fromSettings) => { if (fromSettings) { diff --git a/shared/stores/settings-email.tsx b/shared/stores/settings-email.tsx index 935f43208173..7bee6ce6f7e5 100644 --- a/shared/stores/settings-email.tsx +++ b/shared/stores/settings-email.tsx @@ -66,7 +66,7 @@ export interface State extends Store { } } -export const useSettingsEmailState = Z.createZustand((set, get) => { +export const useSettingsEmailState = Z.createZustand('settings-email', (set, get) => { const dispatch: State['dispatch'] = { addEmail: (email, searchable) => { set(s => { diff --git a/shared/stores/settings-notifications.tsx b/shared/stores/settings-notifications.tsx index c22a90a587f0..81b85630deae 100644 --- a/shared/stores/settings-notifications.tsx +++ b/shared/stores/settings-notifications.tsx @@ -59,7 +59,7 @@ export interface State extends Store { } } -export const useSettingsNotifState = Z.createZustand((set, get) => { +export const useSettingsNotifState = Z.createZustand('settings-notifications', (set, get) => { const dispatch: State['dispatch'] = { refresh: () => { const f = async () => { diff --git a/shared/stores/settings-password.tsx b/shared/stores/settings-password.tsx index db74d4e6f2fe..fcdf231828e2 100644 --- a/shared/stores/settings-password.tsx +++ b/shared/stores/settings-password.tsx @@ -43,7 +43,7 @@ export interface State extends Store { } } -export const usePWState = Z.createZustand((set, get) => { +export const usePWState = Z.createZustand('settings-password', (set, get) => { const dispatch: State['dispatch'] = { loadHasRandomPw: () => { // Once loaded, do not issue this RPC again. This field can only go true -> diff --git a/shared/stores/settings-phone.tsx b/shared/stores/settings-phone.tsx index eefe6932475d..c4ad240a0e64 100644 --- a/shared/stores/settings-phone.tsx +++ b/shared/stores/settings-phone.tsx @@ -85,7 +85,7 @@ export interface State extends Store { } } -export const useSettingsPhoneState = Z.createZustand((set, get) => { +export const useSettingsPhoneState = Z.createZustand('settings-phone', (set, get) => { const dispatch: State['dispatch'] = { addPhoneNumber: (phoneNumber, searchable) => { const f = async () => { diff --git a/shared/stores/settings.tsx b/shared/stores/settings.tsx index d9bb8b826aaf..987de6464801 100644 --- a/shared/stores/settings.tsx +++ b/shared/stores/settings.tsx @@ -57,7 +57,7 @@ export interface State extends Store { } let maybeLoadAppLinkOnce = false -export const useSettingsState = Z.createZustand((set, get) => { +export const useSettingsState = Z.createZustand('settings', (set, get) => { const maybeLoadAppLink = () => { const phones = get().dispatch.defer.getSettingsPhonePhones() if (!phones || phones.size > 0) { diff --git a/shared/stores/signup.tsx b/shared/stores/signup.tsx index a73a84250abb..213f66fe9bb0 100644 --- a/shared/stores/signup.tsx +++ b/shared/stores/signup.tsx @@ -61,7 +61,7 @@ export interface State extends Store { } } -export const useSignupState = Z.createZustand((set, get) => { +export const useSignupState = Z.createZustand('signup', (set, get) => { const noErrors = () => { const {devicenameError, emailError} = get() const {nameError, usernameError, signupError, usernameTaken} = get() diff --git a/shared/stores/teams.tsx b/shared/stores/teams.tsx index d13bae8365b2..b2e45bc26310 100644 --- a/shared/stores/teams.tsx +++ b/shared/stores/teams.tsx @@ -1059,7 +1059,7 @@ export interface State extends Store { } } -export const useTeamsState = Z.createZustand((set, get) => { +export const useTeamsState = Z.createZustand('teams', (set, get) => { const dispatch: State['dispatch'] = { addMembersWizardPushMembers: members => { const f = async () => { diff --git a/shared/stores/tracker.tsx b/shared/stores/tracker.tsx index d5600c2219a4..50ddf944de30 100644 --- a/shared/stores/tracker.tsx +++ b/shared/stores/tracker.tsx @@ -208,7 +208,7 @@ const rpcResultToStatus = (result: T.RPCGen.Identify3ResultType) => { return 'error' } } -export const useTrackerState = Z.createZustand((set, get) => { +export const useTrackerState = Z.createZustand('tracker', (set, get) => { const dispatch: State['dispatch'] = { changeFollow: (guiID, follow) => { const f = async () => { diff --git a/shared/stores/unlock-folders.tsx b/shared/stores/unlock-folders.tsx index d8fa1d8dd573..6098ef24e354 100644 --- a/shared/stores/unlock-folders.tsx +++ b/shared/stores/unlock-folders.tsx @@ -26,7 +26,7 @@ export interface State extends Store { } // this store is only in play in the remote window, its launched by ConfigConstants.unlockFoldersDevices -export const useUnlockFoldersState = Z.createZustand((set, _get) => { +export const useUnlockFoldersState = Z.createZustand('unlock-folders', (set, _get) => { const dispatch: State['dispatch'] = { onBackFromPaperKey: () => { set(s => { diff --git a/shared/stores/users.tsx b/shared/stores/users.tsx index 618264f110ac..71113499f7e5 100644 --- a/shared/stores/users.tsx +++ b/shared/stores/users.tsx @@ -36,7 +36,7 @@ export interface State extends Store { } } -export const useUsersState = Z.createZustand((set, get) => { +export const useUsersState = Z.createZustand('users', (set, get) => { const dispatch: State['dispatch'] = { getBio: username => { const f = async () => { diff --git a/shared/stores/waiting.tsx b/shared/stores/waiting.tsx index effcb51b5006..dbcf4734cdbd 100644 --- a/shared/stores/waiting.tsx +++ b/shared/stores/waiting.tsx @@ -26,7 +26,7 @@ const getKeys = (k?: string | ReadonlyArray) => { return k } -export const useWaitingState = Z.createZustand((set, get) => { +export const useWaitingState = Z.createZustand('waiting', (set, get) => { const changeHelper = (keys: string | ReadonlyArray, diff: 1 | -1, error?: RPCError) => { set(s => { getKeys(keys).forEach(k => { diff --git a/shared/stores/wallets.tsx b/shared/stores/wallets.tsx index 0305e954dfd6..f987572328eb 100644 --- a/shared/stores/wallets.tsx +++ b/shared/stores/wallets.tsx @@ -29,7 +29,7 @@ interface State extends Store { resetState: 'default' } } -export const useState = Z.createZustand((set, get) => { +export const useState = Z.createZustand('wallets', (set, get) => { const dispatch: State['dispatch'] = { load: () => { const f = async () => { diff --git a/shared/stores/whats-new.tsx b/shared/stores/whats-new.tsx index c32512b33505..e0cc28aaf8bc 100644 --- a/shared/stores/whats-new.tsx +++ b/shared/stores/whats-new.tsx @@ -85,7 +85,7 @@ export interface State extends Store { } anyVersionsUnseen: () => boolean } -export const useWhatsNewState = Z.createZustand((set, get) => { +export const useWhatsNewState = Z.createZustand('whats-new', (set, get) => { const dispatch: State['dispatch'] = { resetState: 'default', updateLastSeen: lastSeenItem => { diff --git a/shared/util/zustand.tsx b/shared/util/zustand.tsx index b3d223636d5b..76d83d82b1a7 100644 --- a/shared/util/zustand.tsx +++ b/shared/util/zustand.tsx @@ -20,12 +20,26 @@ type HasReset = { const resetters: ((isDebug?: boolean) => void)[] = [] const resettersAndDelete: ((isDebug?: boolean) => void)[] = [] +// HMR store registry — preserves store instances across hot module reloads +// Uses globalThis so the registry survives module re-evaluation during HMR +// eslint-disable-next-line +const _hmrRegistry: Map = __DEV__ ? ((globalThis as any).__ZUSTAND_HMR__ ??= new Map()) : new Map() + // Auto adds immer and keeps track of resets export const createZustand = ( - initializer: StateCreator + hmrKeyOrInitializer: string | StateCreator, + maybeInitializer?: StateCreator ) => { + const hmrKey = typeof hmrKeyOrInitializer === 'string' ? hmrKeyOrInitializer : undefined + const initializer = typeof hmrKeyOrInitializer === 'string' ? maybeInitializer! : hmrKeyOrInitializer + const f = immerZustand(initializer) const store = create(f) + + // During HMR, return the existing store to preserve state and subscribers + if (__DEV__ && hmrKey && _hmrRegistry.has(hmrKey)) { + return _hmrRegistry.get(hmrKey) as typeof store + } // includes dispatch, custom overrides typically don't const initialState = store.getState() // wrap so we log all exceptions @@ -58,6 +72,11 @@ export const createZustand = ( } else { resetters.push(resetFunc) } + + if (__DEV__ && hmrKey) { + _hmrRegistry.set(hmrKey, store) + } + return store } From e8fc8d468159cf2044cbf4e5e39e9ab5932c66e8 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 24 Feb 2026 22:38:54 -0500 Subject: [PATCH 11/12] WIP --- .../main/java/com/reactnativekb/KbModule.kt | 25 +++++++------------ .../java/io/keybase/ossifrage/MainActivity.kt | 17 +++++-------- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index d8e277578a87..db95666068a6 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -33,7 +33,6 @@ import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.facebook.react.module.annotations.ReactModule -import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.turbomodule.core.CallInvokerHolderImpl import com.google.android.gms.tasks.OnCompleteListener import com.google.android.gms.tasks.Task @@ -747,19 +746,17 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { private fun emitPushNotificationInternal(notification: Bundle) { android.util.Log.d("KbModule", "emitPushNotificationInternal called") - if (reactContext.hasActiveCatalystInstance()) { - android.util.Log.d("KbModule", "emitPushNotificationInternal has active catalyst instance, emitting event") + if (reactContext.hasActiveReactInstance()) { + android.util.Log.d("KbModule", "emitPushNotificationInternal has active react instance, emitting event") try { val payload = Arguments.fromBundle(notification) - reactContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit("onPushNotification", payload) + reactContext.emitDeviceEvent("onPushNotification", payload) android.util.Log.d("KbModule", "emitPushNotificationInternal event emitted successfully") } catch (e: Exception) { android.util.Log.e("KbModule", "emitPushNotificationInternal failed to emit: " + e.message) } } else { - android.util.Log.w("KbModule", "emitPushNotificationInternal no active catalyst instance") + android.util.Log.w("KbModule", "emitPushNotificationInternal no active react instance") } } @@ -877,7 +874,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { try { Thread.currentThread().setName("ReadFromKBLib") val data: ByteArray = readArr() - if (!reactContext.hasActiveCatalystInstance()) { + if (!reactContext.hasActiveReactInstance()) { NativeLogger.info(NAME.toString() + ": JS Bridge is dead, dropping engine message: " + data) } @@ -896,7 +893,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { NativeLogger.error("Exception in ReadFromKBLib.run", e) } } - } while (!Thread.currentThread().isInterrupted() && reactContext.hasActiveCatalystInstance()) + } while (!Thread.currentThread().isInterrupted() && reactContext.hasActiveReactInstance()) } } @@ -938,9 +935,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { private fun sendHardwareKeyEvent(keyName: String) { val params = Arguments.createMap() params.putString("pressedKey", keyName) - reactContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit(HW_KEY_EVENT, params) + reactContext.emitDeviceEvent(HW_KEY_EVENT, params) } companion object { @@ -991,12 +986,10 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { // engine private fun relayReset(reactContext: ReactApplicationContext) { - if (!reactContext.hasActiveCatalystInstance()) { + if (!reactContext.hasActiveReactInstance()) { NativeLogger.info(NAME.toString() + ": JS Bridge is dead, Can't send EOF message") } else { - reactContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit(RPC_META_EVENT_NAME, RPC_META_EVENT_ENGINE_RESET) + reactContext.emitDeviceEvent(RPC_META_EVENT_NAME, RPC_META_EVENT_ENGINE_RESET) } } } diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt index 5099014b8a57..00b112ea4e2d 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt @@ -25,7 +25,6 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactContext import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.defaults.DefaultReactActivityDelegate -import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.modules.core.PermissionListener import com.reactnativekb.DarkModePreference import com.reactnativekb.KbModule @@ -300,10 +299,6 @@ class MainActivity : ReactActivity() { NativeLogger.info("MainActivity.handleIntent: no react context, will retry") return false } - val emitter = rc.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) ?: run { - NativeLogger.info("MainActivity.handleIntent: no emitter, will retry") - return false - } if (!jsIsListening) { NativeLogger.info("MainActivity.handleIntent: JS not listening yet, will retry") return false @@ -332,12 +327,12 @@ class MainActivity : ReactActivity() { val bundle1 = bundleFromNotification.clone() as Bundle val bundle2 = bundleFromNotification.clone() as Bundle val payload1 = Arguments.fromBundle(bundle1) - emitter.emit( + rc.emitDeviceEvent( "initialIntentFromNotification", payload1 ) val payload2 = Arguments.fromBundle(bundle2) - emitter.emit( + rc.emitDeviceEvent( "onPushNotification", payload2 ) @@ -388,7 +383,7 @@ class MainActivity : ReactActivity() { // Text-type intent (e.g. URL from Chrome): prefer text over any preview images val args = Arguments.createMap() args.putString("text", text ?: textPayload) - emitter.emit("onShareData", args) + rc.emitDeviceEvent("onShareData", args) didSomething = true } else if (filePaths.isNotEmpty()) { val args = Arguments.createMap() @@ -397,18 +392,18 @@ class MainActivity : ReactActivity() { lPaths.pushString(path) } args.putArray("localPaths", lPaths) - emitter.emit("onShareData", args) + rc.emitDeviceEvent("onShareData", args) didSomething = true } else if (textPayload.isNotEmpty()) { // Fallback: non-text MIME but no files resolved, send text val args = Arguments.createMap() args.putString("text", textPayload) - emitter.emit("onShareData", args) + rc.emitDeviceEvent("onShareData", args) didSomething = true } else if (uris.isNotEmpty()) { val args = Arguments.createMap() args.putArray("localPaths", Arguments.createArray()) - emitter.emit("onShareData", args) + rc.emitDeviceEvent("onShareData", args) didSomething = true } } From 743aac0703d6bef08561b40d059914941dc01fff Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 25 Feb 2026 16:58:47 -0500 Subject: [PATCH 12/12] modernize jsi (#28941) * native level cleanup (#28942) * encode msgpack on cpp side. rpc on just handle arrays. --- .../react-native-kb/android/CMakeLists.txt | 6 +- .../react-native-kb/android/cpp-adapter.cpp | 201 +++------- .../com/reactnativekb/DarkModePrefHelper.kt | 2 - .../com/reactnativekb/DarkModePreference.kt | 2 - .../main/java/com/reactnativekb/GuiConfig.kt | 14 +- .../main/java/com/reactnativekb/KbModule.kt | 321 +++------------ .../main/java/com/reactnativekb/KbPackage.kt | 13 +- .../java/com/reactnativekb/NativeLogger.kt | 7 +- .../java/com/reactnativekb/PathResolver.kt | 92 ++--- .../com/reactnativekb/ReadFileAsString.kt | 29 +- .../react-native-kb/cpp/react-native-kb.cpp | 377 +++++++++++++----- .../react-native-kb/cpp/react-native-kb.h | 61 ++- .../react-native-kb/ios/KBJSScheduler.cpp | 26 -- rnmodules/react-native-kb/ios/KBJSScheduler.h | 29 -- rnmodules/react-native-kb/ios/Kb.h | 3 +- rnmodules/react-native-kb/ios/Kb.mm | 201 +++------- .../react-native-kb/react-native-kb.podspec | 6 +- rnmodules/react-native-kb/src/index.tsx | 26 +- .../ossifrage/ChatBroadcastReceiver.kt | 44 +- .../CustomBitmapMemoryCacheParamsSupplier.kt | 11 +- .../ossifrage/KBInstallReferrerListener.kt | 8 +- .../io/keybase/ossifrage/KBPushNotifier.kt | 29 +- .../io/keybase/ossifrage/KBReactPackage.kt | 5 +- .../java/io/keybase/ossifrage/KeyStore.kt | 15 +- .../KeybasePushNotificationListenerService.kt | 17 +- .../java/io/keybase/ossifrage/MainActivity.kt | 28 +- .../io/keybase/ossifrage/MainApplication.kt | 10 +- .../ossifrage/keystore/KeyStoreHelper.kt | 39 +- .../keybase/ossifrage/modules/NativeLogger.kt | 2 +- .../ossifrage/modules/StorybookConstants.kt | 4 +- .../keybase/ossifrage/util/DeviceLockType.kt | 39 +- shared/desktop/yarn-helper/index.tsx | 4 +- shared/engine/index.platform.native.tsx | 20 +- shared/globals.d.ts | 4 +- shared/ios/Keybase/AppDelegate.swift | 136 ++++--- shared/ios/Keybase/Fs.swift | 79 ++-- shared/ios/Keybase/Info.plist | 4 + shared/ios/Keybase/PerfFPSMonitor.swift | 7 +- shared/ios/Keybase/Pusher.swift | 9 +- .../ios/Keybase/ShareIntentDonatorImpl.swift | 17 +- .../KeybaseShare/ShareViewController.swift | 23 +- shared/ios/Podfile.lock | 2 +- shared/package.json | 1 + 43 files changed, 833 insertions(+), 1140 deletions(-) delete mode 100644 rnmodules/react-native-kb/ios/KBJSScheduler.cpp delete mode 100644 rnmodules/react-native-kb/ios/KBJSScheduler.h diff --git a/rnmodules/react-native-kb/android/CMakeLists.txt b/rnmodules/react-native-kb/android/CMakeLists.txt index c016c8798d8a..92cacd494863 100644 --- a/rnmodules/react-native-kb/android/CMakeLists.txt +++ b/rnmodules/react-native-kb/android/CMakeLists.txt @@ -2,7 +2,7 @@ project(cpp) cmake_minimum_required(VERSION 3.4.1) set (CMAKE_VERBOSE_MAKEFILE ON) -set (CMAKE_CXX_STANDARD 17) +set (CMAKE_CXX_STANDARD 20) set (NODE_MODULES_DIR "${CMAKE_SOURCE_DIR}/../..") add_library(cpp @@ -16,12 +16,12 @@ message(INFO "params: ${NODE_MODULES_DIR}") # Specifies a path to native header files. include_directories( ../cpp - "${NODE_MODULES_DIR}/msgpack-cxx-6.1.0/include" + "${NODE_MODULES_DIR}/msgpack-cxx-7.0.0/include" ) set_target_properties( cpp PROPERTIES - CXX_STANDARD 17 + CXX_STANDARD 20 CXX_EXTENSIONS OFF POSITION_INDEPENDENT_CODE ON ) diff --git a/rnmodules/react-native-kb/android/cpp-adapter.cpp b/rnmodules/react-native-kb/android/cpp-adapter.cpp index f7ed9c8da652..b11e52b4e737 100644 --- a/rnmodules/react-native-kb/android/cpp-adapter.cpp +++ b/rnmodules/react-native-kb/android/cpp-adapter.cpp @@ -1,158 +1,81 @@ -// https://github.com/ammarahm-ed/react-native-jsi-template/blob/master/android/cpp-adapter.cpp -#include "pthread.h" #include "react-native-kb.h" +#include #include -#include +#include #include -#include #include -#include using namespace facebook; using namespace facebook::jsi; -using namespace std; -using namespace kb; +using namespace facebook::react; -JavaVM *java_vm = NULL; -jclass java_class; -jobject java_object; - -/** - * A simple callback function that allows us to detach current JNI Environment - * when the thread - * See https://stackoverflow.com/a/30026231 for detailed explanation - */ - -void DeferThreadDetach(JNIEnv *env) { - static pthread_key_t thread_key; - - // Set up a Thread Specific Data key, and a callback that - // will be executed when a thread is destroyed. - // This is only done once, across all threads, and the value - // associated with the key for any given thread will initially - // be NULL. - static auto run_once = [] { - const auto err = pthread_key_create(&thread_key, [](void *ts_env) { - if (ts_env) { - java_vm->DetachCurrentThread(); - } - }); - if (err) { - // Failed to create TSD key. Throw an exception if you want to. - } - return 0; - }(); - static_cast(run_once); - - // For the callback to actually be executed when a thread exits - // we need to associate a non-NULL value with the key on that thread. - // We can use the JNIEnv* as that value. - const auto ts_env = pthread_getspecific(thread_key); - if (!ts_env) { - if (pthread_setspecific(thread_key, env)) { - // Failed to set thread-specific value for key. Throw an exception if you - // want to. - } - } -} - -/** - * Get a JNIEnv* valid for this thread, regardless of whether - * we're on a native thread or a Java thread. - * If the calling thread is not currently attached to the JVM - * it will be attached, and then automatically detached when the - * thread is destroyed. - * - * See https://stackoverflow.com/a/30026231 for detailed explanation - */ -JNIEnv *GetJniEnv() { - JNIEnv *env = nullptr; - // We still call GetEnv first to detect if the thread already - // is attached. This is done to avoid setting up a DetachCurrentThread - // call on a Java thread. +struct JKbModule : jni::JavaClass { + static constexpr auto kJavaDescriptor = "Lcom/reactnativekb/KbModule;"; +}; - // g_vm is a global. - auto get_env_result = java_vm->GetEnv((void **)&env, JNI_VERSION_1_6); - if (get_env_result == JNI_EDETACHED) { - if (java_vm->AttachCurrentThread(&env, NULL) == JNI_OK) { - DeferThreadDetach(env); - } else { - // Failed to attach thread. Throw an exception if you want to. - } - } else if (get_env_result == JNI_EVERSION) { - // Unsupported JNI version. Throw an exception if you want to. +class KbNativeAdapter { +public: + jni::global_ref jModule_; + std::shared_ptr bridge_; + + explicit KbNativeAdapter(jni::alias_ref jModule) + : jModule_(jni::make_global(jModule)) {} + + void writeToGo(void *ptr, size_t size) { + jni::ThreadScope scope; + auto env = jni::Environment::current(); + auto jba = env->NewByteArray(size); + env->SetByteArrayRegion(jba, 0, size, (jbyte *)ptr); + static auto method = + JKbModule::javaClassStatic() + ->getMethod)>("rpcOnGo"); + method(jModule_, jni::wrap_alias(jba)); + env->DeleteLocalRef(jba); } - return env; -} - -static jstring string2jstring(JNIEnv *env, const string &str) { - return (*env).NewStringUTF(str.c_str()); -} +}; -void install(facebook::jsi::Runtime &jsiRuntime) { - auto rpcOnGo = Function::createFromHostFunction( - jsiRuntime, PropNameID::forAscii(jsiRuntime, "rpcOnGo"), 1, - [](Runtime &runtime, const Value &thisValue, const Value *arguments, - size_t count) -> Value { - return RpcOnGo( - runtime, thisValue, arguments, count, [](void *ptr, size_t size) { - JNIEnv *jniEnv = GetJniEnv(); - java_class = jniEnv->GetObjectClass(java_object); - jmethodID rpcOnGo = - jniEnv->GetMethodID(java_class, "rpcOnGo", "([B)V"); - jbyteArray jba = jniEnv->NewByteArray(size); - jniEnv->SetByteArrayRegion(jba, 0, size, (jbyte *)ptr); - jvalue params[1]; - params[0].l = jba; - jniEnv->CallVoidMethodA(java_object, rpcOnGo, params); +static std::shared_ptr g_adapter; + +static jni::local_ref +getBindingsInstaller(jni::alias_ref thiz) { + g_adapter = std::make_shared(thiz); + + return BindingsInstallerHolder::newObjectCxxArgs( + [adapter = g_adapter](jsi::Runtime &runtime, + const std::shared_ptr &callInvoker) { + if (adapter->bridge_) { + adapter->bridge_->teardown(); + } + adapter->bridge_ = std::make_shared(); + adapter->bridge_->install( + runtime, callInvoker, + [weak = std::weak_ptr(adapter)](void *ptr, size_t size) { + if (auto a = weak.lock()) + a->writeToGo(ptr, size); + }, + [](const std::string &err) { + __android_log_print(ANDROID_LOG_ERROR, "KBBridge", + "JSI error: %s", err.c_str()); }); }); - jsiRuntime.global().setProperty(jsiRuntime, "rpcOnGo", std::move(rpcOnGo)); } -extern "C" JNIEXPORT void JNICALL installJSI(JNIEnv *env, jobject thiz, jlong jsi) { - auto runtime = reinterpret_cast(jsi); - if (runtime) { - install(*runtime); - } - env->GetJavaVM(&java_vm); - java_object = env->NewGlobalRef(thiz); +static void nativeOnDataFromGo(jni::alias_ref thiz, + jni::alias_ref data) { + auto adapter = g_adapter; + if (!adapter || !adapter->bridge_ || !data) + return; + auto pinned = data->pin(); + adapter->bridge_->onDataFromGo(reinterpret_cast(pinned.get()), + pinned.size()); } -extern "C" JNIEXPORT void JNICALL emit(JNIEnv *env, jclass clazz, jlong jsi, jobject boxedCallInvokerHolder, jbyteArray data) { - auto rPtr = reinterpret_cast(jsi); - auto &runtime = *rPtr; - auto boxedCallInvokerRef = jni::make_local(boxedCallInvokerHolder); - auto callInvokerHolder = - jni::dynamic_ref_cast( - boxedCallInvokerRef); - auto callInvoker = callInvokerHolder->cthis()->getCallInvoker(); - - auto size = static_cast(env->GetArrayLength(data)); - auto payloadBytes = - reinterpret_cast(env->GetByteArrayElements(data, nullptr)); - auto values = PrepRpcOnJS(runtime, payloadBytes, size); - callInvoker->invokeAsync([values, &runtime]() { - RpcOnJS(runtime, values, [](const std::string &err) { - JNIEnv *jniEnv = GetJniEnv(); - java_class = jniEnv->GetObjectClass(java_object); - jmethodID log = - jniEnv->GetMethodID(java_class, "log", "(Ljava/lang/String;)V"); - auto s = string2jstring(jniEnv, err); - jvalue params[1]; - params[0].l = s; - jniEnv->CallVoidMethodA(java_object, log, params); - }); +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) { + return jni::initialize(vm, [] { + jni::findClassStatic("com/reactnativekb/KbModule") + ->registerNatives({ + makeNativeMethod("getBindingsInstaller", getBindingsInstaller), + makeNativeMethod("nativeOnDataFromGo", nativeOnDataFromGo), + }); }); } - -static JNINativeMethod methods[] = { - {"installJSI", "(J)V", (void *)&installJSI}, - {"emit", "(JLcom/facebook/react/turbomodule/core/CallInvokerHolderImpl;[B)V", (void *)&emit}, -}; - - -extern "C" JNIEXPORT void JNICALL Java_com_reactnativekb_KbModule_registerNatives(JNIEnv *env, jobject thiz, jlong jsi) { - jclass clazz = env->FindClass("com/reactnativekb/KbModule"); - env->RegisterNatives(clazz, methods, sizeof(methods)/sizeof(methods[0])); -} diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/DarkModePrefHelper.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/DarkModePrefHelper.kt index 192bc517d23a..1c76672c567f 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/DarkModePrefHelper.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/DarkModePrefHelper.kt @@ -1,7 +1,5 @@ package com.reactnativekb -import kotlin.Throws - object DarkModePrefHelper { fun fromString(prefString: String): DarkModePreference { return when (prefString) { diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/DarkModePreference.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/DarkModePreference.kt index b62b89766471..c318913c62cd 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/DarkModePreference.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/DarkModePreference.kt @@ -1,7 +1,5 @@ package com.reactnativekb -import kotlin.Throws - enum class DarkModePreference { System, AlwaysDark, diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/GuiConfig.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/GuiConfig.kt index c78a5fd2ebcb..0544d0295070 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/GuiConfig.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/GuiConfig.kt @@ -1,27 +1,19 @@ package com.reactnativekb -import androidx.annotation.Nullable - import org.json.JSONException import org.json.JSONObject import java.io.File -class GuiConfig private constructor(filesDir: File?) { - private val filesDir: File? - - init { - this.filesDir = filesDir - } - +class GuiConfig private constructor(private val filesDir: File?) { fun asString(): String? { val filePath = File(filesDir, "/.config/keybase/gui_config.json") - return ReadFileAsString.read(filePath.getAbsolutePath()) + return ReadFileAsString.read(filePath.absolutePath) } fun getDarkMode(): DarkModePreference { return try { - val jsonObject = JSONObject(asString()) + val jsonObject = JSONObject(asString() ?: return DarkModePreference.System) val jsonObjectUI: JSONObject = jsonObject.getJSONObject("ui") val darkModeString: String = jsonObjectUI.getString("darkMode") DarkModePrefHelper.fromString(darkModeString) diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index db95666068a6..4e66357aa29a 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -33,9 +33,9 @@ import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.facebook.react.module.annotations.ReactModule -import com.facebook.react.turbomodule.core.CallInvokerHolderImpl -import com.google.android.gms.tasks.OnCompleteListener -import com.google.android.gms.tasks.Task +import com.facebook.react.turbomodule.core.interfaces.TurboModuleWithJSIBindings +import com.facebook.react.turbomodule.core.interfaces.BindingsInstallerHolder +import com.facebook.proguard.annotations.DoNotStrip import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.FirebaseApp @@ -54,14 +54,11 @@ import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference -import java.util.regex.Matcher -import java.util.regex.Pattern import keybase.Keybase import me.leolin.shortcutbadger.ShortcutBadger import keybase.Keybase.readArr import keybase.Keybase.version import keybase.Keybase.writeArr -import com.facebook.react.common.annotations.FrameworkAPI import android.media.MediaMetadataRetriever import androidx.media3.transformer.TransformationRequest import androidx.media3.transformer.Transformer @@ -79,16 +76,16 @@ import androidx.media3.transformer.DefaultEncoderFactory import java.nio.ByteBuffer import kotlin.math.min -@OptIn(FrameworkAPI::class) -class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { +class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), TurboModuleWithJSIBindings { private val misTestDevice: Boolean private val initialIntent: HashMap? = null private val reactContext: ReactApplicationContext - private external fun registerNatives(jsiPtr: Long) - private external fun installJSI(jsiPtr: Long) - private external fun emit(jsiPtr: Long, jsInvoker: CallInvokerHolderImpl?, data: ByteArray?) + + @DoNotStrip + external override fun getBindingsInstaller(): BindingsInstallerHolder + private external fun nativeOnDataFromGo(data: ByteArray) + private var executor: ExecutorService? = null - private var jsiInstalled: Boolean? = false override fun getName(): String { return NAME @@ -112,170 +109,6 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { // not used } - /* - @ReactMethod - override fun processVideo(path: String, promise: Promise) { - Executors.newSingleThreadExecutor().execute { - try { - val inputFile = File(path) - if (!inputFile.exists()) { - promise.reject("FILE_NOT_FOUND", "Video file not found: $path") - return@execute - } - - val retriever = MediaMetadataRetriever() - try { - retriever.setDataSource(path) - val widthStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) - val heightStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) - val fileSize = inputFile.length() - - val width = widthStr?.toIntOrNull() ?: 0 - val height = heightStr?.toIntOrNull() ?: 0 - val maxPixels = 1920 * 1080 - val maxFileSize = 50L * 1024 * 1024 // 50MB - val pixelCount = width * height - - val needsCompression = pixelCount > maxPixels || fileSize > maxFileSize - NativeLogger.info("Video processing: width=$width, height=$height, pixelCount=$pixelCount, fileSize=$fileSize, needsCompression=$needsCompression") - - if (!needsCompression) { - Log.i("VideoCompression", "Video does not need compression, returning original path") - promise.resolve(path) - return@execute - } - - val outputFile = File(inputFile.parent, "${inputFile.nameWithoutExtension}.processed.mp4") - NativeLogger.info("Starting video compression: $path -> ${outputFile.absolutePath}") - compressVideo(path, outputFile.absolutePath, width, height, maxPixels) - - // Verify output file exists and is valid before resolving - if (!outputFile.exists()) { - throw IllegalStateException("Compressed video file does not exist: ${outputFile.absolutePath}") - } - val outputSize = outputFile.length() - if (outputSize == 0L) { - throw IllegalStateException("Compressed video file is empty: ${outputFile.absolutePath}") - } - NativeLogger.info("Video compression completed successfully: ${outputFile.absolutePath}, size=$outputSize bytes (original=${inputFile.length()} bytes)") - promise.resolve(outputFile.absolutePath) - } finally { - retriever.release() - } - } catch (e: Exception) { - NativeLogger.error("Error compressing video", e) - promise.reject("COMPRESSION_ERROR", "Failed to compress video: ${e.message}", e) - } - } - } - */ - - /* - private fun compressVideo(inputPath: String, outputPath: String, originalWidth: Int, originalHeight: Int, maxPixels: Int) { - val (outputWidth, outputHeight) = calculateOutputDimensions(originalWidth, originalHeight, maxPixels) - val targetBitrate = calculateBitrate(outputWidth, outputHeight) - - // Ensure output directory exists - val outputFile = File(outputPath) - outputFile.parentFile?.mkdirs() - - // Use Media3 Transformer for simple, reliable transcoding - // Note: Bitrate is controlled by the encoder automatically based on resolution - // Media3 Transformer doesn't expose direct bitrate control in TransformationRequest - // Transformer and all Media3 objects must be created and used on a thread with a Looper (main thread) - val latch = CountDownLatch(1) - val exceptionRef = AtomicReference(null) - val mainHandler = Handler(Looper.getMainLooper()) - - mainHandler.post { - try { - NativeLogger.info("compressVideo: Creating Media3 objects on main thread") - // Create file URI properly - val inputFile = File(inputPath) - val inputUri = Uri.fromFile(inputFile) - val mediaItem = MediaItem.fromUri(inputUri) - - // Apply scaling transformation if needed - val editedMediaItemBuilder = EditedMediaItem.Builder(mediaItem) - if (outputWidth != originalWidth || outputHeight != originalHeight) { - val scaleX = outputWidth.toFloat() / originalWidth.toFloat() - val scaleY = outputHeight.toFloat() / originalHeight.toFloat() - NativeLogger.info("compressVideo: Scaling from ${originalWidth}x${originalHeight} to ${outputWidth}x${outputHeight} (scale=$scaleX,$scaleY)") - val scaleTransformation = ScaleAndRotateTransformation.Builder() - .setScale(scaleX, scaleY) - .build() - // Effects class wraps video effects and audio processors - // Constructor takes (audioProcessors, videoEffects) as positional parameters - editedMediaItemBuilder.setEffects( - Effects(listOf(), listOf(scaleTransformation)) - ) - } - val editedMediaItem = editedMediaItemBuilder.build() - - // Set video encoder settings with target bitrate to actually compress the video - val videoEncoderSettings = VideoEncoderSettings.Builder() - .setBitrate(targetBitrate) - .build() - - // Create encoder factory with video encoder settings - val encoderFactory = DefaultEncoderFactory.Builder(reactContext) - .setRequestedVideoEncoderSettings(videoEncoderSettings) - .build() - - val transformationRequest = TransformationRequest.Builder() - .setVideoMimeType(MimeTypes.VIDEO_H264) - .setAudioMimeType(MimeTypes.AUDIO_AAC) - .build() - - NativeLogger.info("compressVideo: Creating Transformer with listener") - val transformer = Transformer.Builder(reactContext) - .setTransformationRequest(transformationRequest) - .setEncoderFactory(encoderFactory) - .addListener(object : Listener { - override fun onCompleted(composition: Composition, result: ExportResult) { - NativeLogger.info("compressVideo: Transformation completed successfully") - latch.countDown() - } - - override fun onError(composition: Composition, result: ExportResult, exception: ExportException) { - NativeLogger.error("compressVideo: Transformation error", exception) - exceptionRef.set(exception) - latch.countDown() - } - }) - .build() - - NativeLogger.info("compressVideo: Starting Transformer.start() (asynchronous)") - // Transformer.start() is asynchronous - completion is signaled via Listener callbacks - transformer.start(editedMediaItem, outputPath) - } catch (e: Exception) { - NativeLogger.error("Error in compressVideo Transformer operation", e) - exceptionRef.set(e) - latch.countDown() - } - } - - // Wait for Transformer operation to complete on main thread - // The Listener callbacks will signal completion via latch.countDown() - latch.await() - - // Check if an exception occurred during transformation - val exception = exceptionRef.get() - if (exception != null) { - throw exception - } - - // Validate that output file was created and is valid - if (!outputFile.exists()) { - throw IllegalStateException("Compressed video file was not created: $outputPath") - } - if (outputFile.length() == 0L) { - throw IllegalStateException("Compressed video file is empty: $outputPath") - } - NativeLogger.info("compressVideo: Output file validated - size=${outputFile.length()} bytes") - } - */ - private fun calculateOutputDimensions(width: Int, height: Int, maxPixels: Int): Pair { val pixelCount = width * height if (pixelCount <= maxPixels) { @@ -308,7 +141,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { */ private fun getBuildConfigValue(fieldName: String): Any? { try { - val clazz: Class<*> = Class.forName(reactContext.getPackageName() + ".BuildConfig") + val clazz: Class<*> = Class.forName("${reactContext.packageName}.BuildConfig") val field = clazz.getField(fieldName) return field.get(null) } catch (e: ClassNotFoundException) { @@ -322,7 +155,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { } private fun readGuiConfig(): String? { - return GuiConfig.getInstance(reactContext.getFilesDir())?.asString() + return GuiConfig.getInstance(reactContext.filesDir)?.asString() } @ReactMethod(isBlockingSynchronousMethod = true) @@ -332,28 +165,28 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { var isDeviceSecure = false try { val keyguardManager: KeyguardManager = reactContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager - isDeviceSecure = keyguardManager.isKeyguardSecure() + isDeviceSecure = keyguardManager.isKeyguardSecure } catch (e: Exception) { NativeLogger.warn(": Error reading keyguard secure state", e) } var serverConfig = "" try { - serverConfig = ReadFileAsString.read(reactContext.getCacheDir().getAbsolutePath() + "/Keybase/keybase.app.serverConfig") + serverConfig = ReadFileAsString.read("${reactContext.cacheDir.absolutePath}/Keybase/keybase.app.serverConfig") } catch (e: Exception) { NativeLogger.warn(": Error reading server config", e) } var cacheDir = "" run { - val dir: File? = reactContext.getCacheDir() + val dir: File? = reactContext.cacheDir if (dir != null) { - cacheDir = dir.getAbsolutePath() + cacheDir = dir.absolutePath } } var downloadDir = "" run { val dir: File? = reactContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) if (dir != null) { - downloadDir = dir.getAbsolutePath() + downloadDir = dir.absolutePath } } @@ -377,7 +210,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { override fun getDefaultCountryCode(promise: Promise) { try { val tm: TelephonyManager = reactContext.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager - val countryCode: String = tm.getNetworkCountryIso() + val countryCode: String = tm.networkCountryIso promise.resolve(countryCode) } catch (e: Exception) { promise.reject(e) @@ -403,7 +236,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { override fun androidOpenSettings() { val intent = Intent() intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - val uri: Uri = Uri.fromParts("package", reactContext.getPackageName(), null) + val uri: Uri = Uri.fromParts("package", reactContext.packageName, null) intent.setData(uri) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) reactContext.startActivity(intent) @@ -428,19 +261,16 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { private fun setSecureFlag() { val prefs: SharedPreferences = reactContext.getSharedPreferences("SecureFlag", Context.MODE_PRIVATE) val setSecure: Boolean = prefs.getBoolean("setSecure", !misTestDevice) - val activity: Activity? = reactContext.getCurrentActivity() + val activity: Activity? = reactContext.currentActivity if (activity != null) { - activity.runOnUiThread(object : Runnable { - @Override - override fun run() { - val window: Window = activity.getWindow() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH && setSecure) { - window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } + activity.runOnUiThread { + val window: Window = activity.window + if (setSecure) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } - }) + } } } @@ -448,12 +278,13 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { @ReactMethod override fun shareListenersRegistered() { try { - val activity: Activity? = reactContext.getCurrentActivity() + val activity: Activity? = reactContext.currentActivity if (activity != null) { val m: Method = activity.javaClass.getMethod("shareListenersRegistered") m.invoke(activity) } } catch (ex: Exception) { + NativeLogger.warn("Error calling shareListenersRegistered", ex) } } @@ -497,12 +328,12 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { private fun handleNonTextFileSharing(file: File, intent: Intent, promise: Promise) { try { // note in JS initPlatformSpecific changes the cache dir so this works - val fileUri: Uri = FileProvider.getUriForFile(reactContext, reactContext.getPackageName() + ".fileprovider", file) + val fileUri: Uri = FileProvider.getUriForFile(reactContext, "${reactContext.packageName}.fileprovider", file) intent.putExtra(Intent.EXTRA_STREAM, fileUri) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) startSharing(intent, promise) } catch (ex: Exception) { - promise.reject(Error("Error sharing file " + ex.getLocalizedMessage())) + promise.reject(Error("Error sharing file ${ex.localizedMessage}")) } } @@ -551,28 +382,28 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { override fun getRegistrationToken(promise: Promise) { ensureFirebase() FirebaseMessaging.getInstance().getToken() - .addOnCompleteListener(OnCompleteListener { task -> - if (!task.isSuccessful()) { - NativeLogger.info("Fetching FCM registration token failed " + task.getException()) - promise.reject("Fetching FCM registration token failed") - return@OnCompleteListener + .addOnCompleteListener { task -> + if (!task.isSuccessful) { + NativeLogger.info("Fetching FCM registration token failed ${task.exception}") + promise.reject("E_FCM_TOKEN", "Fetching FCM registration token failed") + return@addOnCompleteListener } // Get new FCM registration token val token: String? = task.result if (token == null) { - promise.reject("null token") - return@OnCompleteListener + promise.reject("E_FCM_TOKEN", "null token") + return@addOnCompleteListener } NativeLogger.info("Got token: $token") promise.resolve(token) - }) + } } // Unlink @Throws(IOException::class) private fun deleteRecursive(fileOrDirectory: File) { - if (fileOrDirectory.isDirectory()) { + if (fileOrDirectory.isDirectory) { val files = fileOrDirectory.listFiles() if (files == null) { throw NullPointerException("Received null trying to list files of directory '$fileOrDirectory'") @@ -594,16 +425,13 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { misTestDevice = isTestDevice(reactContext) setSecureFlag() reactContext.addLifecycleEventListener(object : LifecycleEventListener { - @Override override fun onHostResume() { setSecureFlag() } - @Override override fun onHostPause() { } - @Override override fun onHostDestroy() { } }) @@ -635,7 +463,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { deleteRecursive(File(normalizedPath)) promise.resolve(true) } catch (err: Exception) { - promise.reject("EUNSPECIFIED", err.getLocalizedMessage()) + promise.reject("EUNSPECIFIED", err.localizedMessage) } } @@ -647,20 +475,20 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { val stat: WritableMap = Arguments.createMap() if (isAsset(path)) { val name: String = path.replace(FILE_PREFIX_BUNDLE_ASSET, "") - val fd: AssetFileDescriptor = reactContext.getAssets().openFd(name) + val fd: AssetFileDescriptor = reactContext.assets.openFd(name) stat.putString("filename", name) stat.putString("path", path) stat.putString("type", "asset") - stat.putString("size", fd.getLength().toString()) + stat.putString("size", fd.length.toString()) stat.putInt("lastModified", 0) } else { val target = File(path) if (!target.exists()) { return null } - stat.putString("filename", target.getName()) - stat.putString("path", target.getPath()) - stat.putString("type", if (target.isDirectory()) "directory" else "file") + stat.putString("filename", target.name) + stat.putString("path", target.path) + stat.putString("type", if (target.isDirectory) "directory" else "file") stat.putString("size", target.length().toString()) val lastModified: String = target.lastModified().toString() stat.putString("lastModified", lastModified) @@ -693,6 +521,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { size = sizeStr.toLong() } } + @Suppress("DEPRECATION") dm.addCompletedDownload( if (config.hasKey("title")) config.getString("title") else "", if (config.hasKey("description")) config.getString("description") else "", @@ -704,7 +533,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { ) promise.resolve(null) } catch (ex: Exception) { - promise.reject("EUNSPECIFIED", ex.getLocalizedMessage()) + promise.reject("EUNSPECIFIED", ex.localizedMessage) } } @@ -713,13 +542,14 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { @ReactMethod override fun androidAppColorSchemeChanged(prefString: String) { try { - val activity: Activity? = reactContext.getCurrentActivity() + val activity: Activity? = reactContext.currentActivity if (activity != null) { val m: Method = activity.javaClass.getMethod("setBackgroundColor", DarkModePreference::class.java) val pref: DarkModePreference = DarkModePrefHelper.fromString(prefString) m.invoke(activity, pref) } } catch (ex: Exception) { + NativeLogger.warn("Error calling androidAppColorSchemeChanged", ex) } } @@ -797,20 +627,8 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { @ReactMethod(isBlockingSynchronousMethod = true) override fun install(): Boolean { - try { - System.loadLibrary("cpp") - jsiInstalled = true - val jsi = reactContext.javaScriptContextHolder?.get() - if (jsi != null) { - registerNatives(jsi) - installJSI(jsi) - } else { - throw Exception("No context holder") - } - } catch (exception: Exception) { - NativeLogger.error("Exception in installJSI", exception) - } - return true; + // No-op: JSI bindings are now installed via TurboModuleWithJSIBindings.getBindingsInstaller() + return true } @ReactMethod @@ -848,7 +666,6 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { init { this.reactContext = reactContext reactContext.addLifecycleEventListener(object : LifecycleEventListener { - @Override override fun onHostResume() { if (executor == null) { val ex = Executors.newSingleThreadExecutor() @@ -857,35 +674,25 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { } } - @Override override fun onHostPause() { } - @Override override fun onHostDestroy() { destroy() } }) } - @Override override fun run() { do { try { - Thread.currentThread().setName("ReadFromKBLib") + Thread.currentThread().name = "ReadFromKBLib" val data: ByteArray = readArr() if (!reactContext.hasActiveReactInstance()) { - NativeLogger.info(NAME.toString() + ": JS Bridge is dead, dropping engine message: " + data) - - } - - val callInvoker: CallInvokerHolderImpl = reactContext.getJSCallInvokerHolder() as CallInvokerHolderImpl - val jsi = reactContext.javaScriptContextHolder?.get() - if (jsi != null) { - emit(jsi, callInvoker, data) - } else { - throw Exception("No context holder") + NativeLogger.info("$NAME: JS Bridge is dead, dropping engine message") + continue } + nativeOnDataFromGo(data) } catch (e: Exception) { if (e.message != null && e.message.equals("Read error: EOF")) { NativeLogger.info("Got EOF from read. Likely because of reset.") @@ -893,7 +700,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { NativeLogger.error("Exception in ReadFromKBLib.run", e) } } - } while (!Thread.currentThread().isInterrupted() && reactContext.hasActiveReactInstance()) + } while (!Thread.currentThread().isInterrupted && reactContext.hasActiveReactInstance()) } } @@ -910,7 +717,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { // We often hit this timeout during app resume, e.g. hit the back // button to go to home screen and then tap Keybase app icon again. if (executor?.awaitTermination(3, TimeUnit.SECONDS)== false) { - NativeLogger.warn(NAME.toString() + ": Executor pool didn't shut down cleanly") + NativeLogger.warn("$NAME: Executor pool didn't shut down cleanly") } executor = null } catch (e: Exception) { @@ -939,10 +746,14 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { } companion object { + init { + System.loadLibrary("cpp") + } + const val NAME: String = "Kb" - private val RN_NAME: String = "ReactNativeJS" - private val RPC_META_EVENT_NAME: String = "kb-meta-engine-event" - private val RPC_META_EVENT_ENGINE_RESET: String = "kb-engine-reset" + private const val RN_NAME: String = "ReactNativeJS" + private const val RPC_META_EVENT_NAME: String = "kb-meta-engine-event" + private const val RPC_META_EVENT_ENGINE_RESET: String = "kb-engine-reset" private const val MAX_TEXT_FILE_SIZE = 100 * 1024 // 100 kiB private val LINE_SEPARATOR: String? = System.getProperty("line.separator") private const val HW_KEY_EVENT: String = "hardwareKeyPressed" @@ -982,12 +793,12 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext) { return "true".equals(testLabSetting) } - private val FILE_PREFIX_BUNDLE_ASSET: String = "bundle-assets://" + private const val FILE_PREFIX_BUNDLE_ASSET: String = "bundle-assets://" // engine private fun relayReset(reactContext: ReactApplicationContext) { if (!reactContext.hasActiveReactInstance()) { - NativeLogger.info(NAME.toString() + ": JS Bridge is dead, Can't send EOF message") + NativeLogger.info("$NAME: JS Bridge is dead, Can't send EOF message") } else { reactContext.emitDeviceEvent(RPC_META_EVENT_NAME, RPC_META_EVENT_ENGINE_RESET) } diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbPackage.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbPackage.kt index 021c99ae8cf0..8f88320027f6 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbPackage.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbPackage.kt @@ -1,18 +1,12 @@ package com.reactnativekb -import androidx.annotation.Nullable - import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.module.model.ReactModuleInfo import com.facebook.react.module.model.ReactModuleInfoProvider -import com.facebook.react.TurboReactPackage - -import java.util.HashMap -import java.util.Map +import com.facebook.react.BaseReactPackage -class KbPackage : TurboReactPackage() { - @Nullable +class KbPackage : BaseReactPackage() { override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { return if (name == KbModule.NAME) { KbModule(reactContext) @@ -23,12 +17,11 @@ class KbPackage : TurboReactPackage() { override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { return ReactModuleInfoProvider { - val moduleInfos: MutableMap = HashMap() + val moduleInfos: MutableMap = mutableMapOf() val isTurboModule = true moduleInfos[KbModule.NAME] = ReactModuleInfo( KbModule.NAME, KbModule.NAME, - false, // canOverrideExistingModule false, // needsEagerInit true, // hasConstants false, // isCxxModule diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/NativeLogger.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/NativeLogger.kt index 17852364de2d..ed81980d427e 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/NativeLogger.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/NativeLogger.kt @@ -12,21 +12,20 @@ import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.WritableArray class NativeLogger(reactContext: ReactApplicationContext?) : ReactContextBaseJavaModule(reactContext) { - @Override override fun getName(): String { return NAME } companion object { - private val NAME: String = "NativeLogger" - private val RN_NAME: String = "ReactNativeJS" + private const val NAME: String = "NativeLogger" + private const val RN_NAME: String = "ReactNativeJS" fun rawLog(tag: String, jsonLog: String) { Log.i(tag + NAME, jsonLog) } private fun formatLine(tagPrefix: String, toLog: String): String { // Copies the Style JS outputs in native/logger.native.tsx - return tagPrefix + NAME + ": [" + System.currentTimeMillis() + ",\"" + toLog + "\"]" + return "${tagPrefix}${NAME}: [${System.currentTimeMillis()},\"$toLog\"]" } fun error(log: String) { diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/PathResolver.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/PathResolver.kt index 1c907d246b15..3d81c772d932 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/PathResolver.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/PathResolver.kt @@ -1,10 +1,8 @@ package com.reactnativekb // part of https://raw.githubusercontent.com/RonRadtke/react-native-blob-util/master/android/src/main/java/com/ReactNativeBlobUtil/Utils/PathResolver.java -import android.annotation.TargetApi import android.content.Context import android.database.Cursor import android.net.Uri -import android.os.Build import android.provider.DocumentsContract import android.provider.MediaStore import android.content.ContentUris @@ -14,21 +12,19 @@ import java.io.File; import java.io.InputStream; import java.io.FileOutputStream; object PathResolver { - @TargetApi(19) fun getRealPathFromURI(context: Context?, uri: Uri?): String? { if (context == null || uri == null) { return null } - val isKitKat: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT // DocumentProvider - if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { + if (DocumentsContract.isDocumentUri(context, uri)) { // ExternalStorageProvider if (isExternalStorageDocument(uri)) { val docId: String = DocumentsContract.getDocumentId(uri) val split: List = docId.split(":") val type = split[0] - if ("primary".equals(type, ignoreCase = true) && context != null) { + if ("primary".equals(type, ignoreCase = true)) { val dir: File? = context.getExternalFilesDir(null) return if (dir != null) dir.toString() + "/" + split[1] else "" } @@ -39,13 +35,13 @@ object PathResolver { val id: String = DocumentsContract.getDocumentId(uri) //Starting with Android O, this "id" is not necessarily a long (row number), //but might also be a "raw:/some/file/path" URL - if (id != null && id.startsWith("raw:/")) { + if (id.startsWith("raw:/")) { val rawuri: Uri = Uri.parse(id) - return rawuri.getPath() + return rawuri.path } var docId: Long? = null //Since Android 10, uri can start with msf scheme like "msf:12345" - if (id != null && id.startsWith("msf:")) { + if (id.startsWith("msf:")) { val split: List = id.split(":") val v = split[1] if (v != null) { @@ -68,40 +64,36 @@ object PathResolver { val docId: String = DocumentsContract.getDocumentId(uri) val split: List = docId.split(":") val type = split[0] - var contentUri: Uri? = null - if ("image".equals(type)) { - contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI - } else if ("video".equals(type)) { - contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI - } else if ("audio".equals(type)) { - contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + val contentUri: Uri? = when (type) { + "image" -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + "video" -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + "audio" -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + else -> null } val selection = "_id=?" val selectionArgs = arrayOf( split[1] ) return getDataColumn(context, contentUri, selection, selectionArgs) - } else if ("content".equals(uri.getScheme(), ignoreCase = true)) { + } else if ("content".equals(uri.scheme, ignoreCase = true)) { // Return the remote address - return if (isGooglePhotosUri(uri)) uri.getLastPathSegment() else getDataColumn(context, uri, null, null) + return if (isGooglePhotosUri(uri)) uri.lastPathSegment else getDataColumn(context, uri, null, null) } else { try { - val cr = context.getContentResolver() - if (cr != null) { - val attachment: InputStream? = cr.openInputStream(uri) - if (attachment != null) { - val filename = getContentName(context.getContentResolver(), uri) - if (filename != null) { - val file = File(context.getCacheDir(), filename) - val tmp = FileOutputStream(file) - val buffer = ByteArray(1024) - while (attachment.read(buffer) > 0) { - tmp.write(buffer) - } - tmp.close() - attachment.close() - return file.getAbsolutePath() + val cr = context.contentResolver + val attachment: InputStream? = cr.openInputStream(uri) + if (attachment != null) { + val filename = getContentName(context.contentResolver, uri) + if (filename != null) { + val file = File(context.cacheDir, filename) + val tmp = FileOutputStream(file) + val buffer = ByteArray(1024) + while (attachment.read(buffer) > 0) { + tmp.write(buffer) } + tmp.close() + attachment.close() + return file.absolutePath } } } catch (e: Exception) { @@ -109,12 +101,12 @@ object PathResolver { return null } } - } else if ("content".equals(uri.getScheme(), ignoreCase = true)) { + } else if ("content".equals(uri.scheme, ignoreCase = true)) { // Return the remote address - return if (isGooglePhotosUri(uri)) uri.getLastPathSegment() else getDataColumn(context, uri, null, null) - } else if ("file".equals(uri.getScheme(), ignoreCase = true)) { - return uri.getPath() + return if (isGooglePhotosUri(uri)) uri.lastPathSegment else getDataColumn(context, uri, null, null) + } else if ("file".equals(uri.scheme, ignoreCase = true)) { + return uri.path } return null } @@ -152,26 +144,18 @@ object PathResolver { if (context == null || uri == null) { return null } - var cursor: Cursor? = null - var result: String? = null val column = "_data" val projection = arrayOf( column ) - try { - cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, - null) - if (cursor != null && cursor.moveToFirst()) { + context.contentResolver.query(uri, projection, selection, selectionArgs, + null)?.use { cursor -> + if (cursor.moveToFirst()) { val index: Int = cursor.getColumnIndexOrThrow(column) - result = cursor.getString(index) + return cursor.getString(index) } - } catch (ex: Exception) { - ex.printStackTrace() - return null - } finally { - if (cursor != null) cursor.close() } - return result + return null } /** @@ -179,7 +163,7 @@ object PathResolver { * @return Whether the Uri authority is ExternalStorageProvider. */ fun isExternalStorageDocument(uri: Uri): Boolean { - return "com.android.externalstorage.documents".equals(uri.getAuthority()) + return "com.android.externalstorage.documents" == uri.authority } /** @@ -187,7 +171,7 @@ object PathResolver { * @return Whether the Uri authority is DownloadsProvider. */ fun isDownloadsDocument(uri: Uri): Boolean { - return "com.android.providers.downloads.documents".equals(uri.getAuthority()) + return "com.android.providers.downloads.documents" == uri.authority } /** @@ -195,7 +179,7 @@ object PathResolver { * @return Whether the Uri authority is MediaProvider. */ fun isMediaDocument(uri: Uri): Boolean { - return "com.android.providers.media.documents".equals(uri.getAuthority()) + return "com.android.providers.media.documents" == uri.authority } /** @@ -203,6 +187,6 @@ object PathResolver { * @return Whether the Uri authority is Google Photos. */ fun isGooglePhotosUri(uri: Uri): Boolean { - return "com.google.android.apps.photos.content".equals(uri.getAuthority()) + return "com.google.android.apps.photos.content" == uri.authority } } diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/ReadFileAsString.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/ReadFileAsString.kt index cecd14f8c4fd..c4238b5f8419 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/ReadFileAsString.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/ReadFileAsString.kt @@ -1,38 +1,17 @@ package com.reactnativekb -import java.io.BufferedReader import java.io.File -import java.io.FileInputStream import java.io.FileNotFoundException import java.io.IOException -import java.io.InputStreamReader object ReadFileAsString { fun read(path: String): String { - var ret = "" - - try { - val inputStream = FileInputStream(File(path)) - - inputStream.use { - val inputStreamReader = InputStreamReader(it) - val bufferedReader = BufferedReader(inputStreamReader) - val stringBuilder = StringBuilder() - - var receiveString: String? - - while (bufferedReader.readLine().also { receiveString = it } != null) { - stringBuilder.append(receiveString) - } - - ret = stringBuilder.toString() - } + return try { + File(path).readText() } catch (e: FileNotFoundException) { - // ignore + "" } catch (e: IOException) { - // ignore + "" } - - return ret } } diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.cpp b/rnmodules/react-native-kb/cpp/react-native-kb.cpp index babec40e935f..1c64ebc08d54 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.cpp +++ b/rnmodules/react-native-kb/cpp/react-native-kb.cpp @@ -1,5 +1,6 @@ #include "react-native-kb.h" -#include +#include +#include #include #include @@ -7,29 +8,35 @@ using namespace facebook; using namespace facebook::jsi; namespace kb { -std::atomic isTornDown{false}; -void Teardown() { isTornDown.store(true); } +struct KBBridge::MsgpackState { + msgpack::unpacker unpacker; +}; -void Tearup() { isTornDown.store(false); } +KBBridge::KBBridge() = default; +KBBridge::~KBBridge() = default; -Value RpcOnGo(Runtime &runtime, const Value &thisValue, const Value *arguments, - size_t count, void (*callback)(void *ptr, size_t size)) { - try { - auto obj = arguments[0].asObject(runtime); - auto buffer = obj.getArrayBuffer(runtime); - auto ptr = buffer.data(runtime); - auto size = buffer.size(runtime); - callback(ptr, size); - return Value(true); - } catch (const std::exception &e) { - throw std::runtime_error("Error in RpcOnGo: " + std::string(e.what())); - } catch (...) { - throw std::runtime_error("Unknown error in RpcOnGo"); +void KBBridge::teardown() { + isTornDown_.store(true); + // Clear cached JSI objects while the runtime is still alive. + // This prevents stale jsi::Function destructors from crashing + // if the bridge outlives the runtime (due to shared_ptr captures). + cachedUint8ArrayCtor_.reset(); + cachedRpcOnJs_.reset(); + cachedRuntime_ = nullptr; +} + +void KBBridge::tearup() { isTornDown_.store(false); } + +void KBBridge::resetCaches(Runtime &runtime) { + if (cachedRuntime_ != &runtime) { + cachedUint8ArrayCtor_.reset(); + cachedRpcOnJs_.reset(); + cachedRuntime_ = &runtime; } } -std::string mpToString(msgpack::object &o) { +static std::string mpToString(msgpack::object &o) { switch (o.type) { case msgpack::type::STR: return o.as(); @@ -46,10 +53,12 @@ std::string mpToString(msgpack::object &o) { } } -Value convertMPToJSI(Runtime &runtime, msgpack::object &o) { +Value KBBridge::convertMPToJSI(Runtime &runtime, void *mpObj) { + auto &o = *static_cast(mpObj); switch (o.type) { case msgpack::type::STR: - return jsi::String::createFromUtf8(runtime, o.as()); + return jsi::String::createFromUtf8(runtime, + reinterpret_cast(o.via.str.ptr), o.via.str.size); case msgpack::type::POSITIVE_INTEGER: return jsi::Value(o.as()); case msgpack::type::NEGATIVE_INTEGER: @@ -69,9 +78,18 @@ Value convertMPToJSI(Runtime &runtime, msgpack::object &o) { auto *p = o.via.map.ptr; auto *const pend = o.via.map.ptr + o.via.map.size; for (; p < pend; ++p) { - auto key = mpToString(p->key); - auto val = convertMPToJSI(runtime, p->val); - obj.setProperty(runtime, jsi::String::createFromUtf8(runtime, key), val); + auto val = convertMPToJSI(runtime, &p->val); + auto &k = p->key; + if (k.type == msgpack::type::STR) { + obj.setProperty(runtime, + jsi::PropNameID::forUtf8(runtime, + reinterpret_cast(k.via.str.ptr), k.via.str.size), + val); + } else { + auto keyStr = mpToString(k); + obj.setProperty(runtime, + jsi::PropNameID::forUtf8(runtime, keyStr), val); + } } return obj; } @@ -79,30 +97,28 @@ Value convertMPToJSI(Runtime &runtime, msgpack::object &o) { auto ptr = o.via.bin.ptr; int size = o.via.bin.size; - // make ArrayBuffer and copy in data - // non-owning - static Function* cachedUint8ArrayCtor = nullptr; - static Runtime* cachedRuntime = nullptr; - if (cachedRuntime != &runtime) { - cachedUint8ArrayCtor = nullptr; - cachedRuntime = &runtime; - } - if (!cachedUint8ArrayCtor) { - auto ctor = runtime.global().getPropertyAsFunction(runtime, "Uint8Array"); - cachedUint8ArrayCtor = new Function(std::move(ctor)); + resetCaches(runtime); + if (!cachedUint8ArrayCtor_) { + auto ctor = + runtime.global().getPropertyAsFunction(runtime, "Uint8Array"); + cachedUint8ArrayCtor_ = std::make_unique(std::move(ctor)); } - Value uint8Array = cachedUint8ArrayCtor->callAsConstructor(runtime, size); + Value uint8Array = + cachedUint8ArrayCtor_->callAsConstructor(runtime, size); Object uint8ArrayObj = uint8Array.asObject(runtime); - ArrayBuffer buffer = uint8ArrayObj.getProperty(runtime, "buffer").asObject(runtime).getArrayBuffer(runtime); + ArrayBuffer buffer = uint8ArrayObj.getProperty(runtime, "buffer") + .asObject(runtime) + .getArrayBuffer(runtime); std::memcpy(buffer.data(runtime), ptr, size); return uint8Array; } case msgpack::type::ARRAY: { auto size = o.via.array.size; jsi::Array arr(runtime, size); - for (int i = 0; i < size; ++i) { - arr.setValueAtIndex(runtime, i, convertMPToJSI(runtime, o.via.array.ptr[i])); + for (uint32_t i = 0; i < size; ++i) { + arr.setValueAtIndex(runtime, i, + convertMPToJSI(runtime, &o.via.array.ptr[i])); } return arr; } @@ -111,81 +127,250 @@ Value convertMPToJSI(Runtime &runtime, msgpack::object &o) { } } -enum class ReadState { needSize, needContent }; -ReadState g_state = ReadState::needSize; -msgpack::unpacker unp; - -ShareValues PrepRpcOnJS(Runtime &runtime, uint8_t *data, int size) { - try { - auto values = std::make_shared>(); - if (size > 0) { - unp.reserve_buffer(size); - std::copy(data, data + size, unp.buffer()); - unp.buffer_consumed(size); - while (true) { - msgpack::object_handle result; - if (unp.next(result)) { - if (g_state == ReadState::needSize) { - g_state = ReadState::needContent; - } else { - values->push_back(std::move(result)); - g_state = ReadState::needSize; +void KBBridge::convertJSIToMP(Runtime &runtime, const Value &value, + void *packer) { + auto &pk = *static_cast *>(packer); + if (value.isNull() || value.isUndefined()) { + pk.pack_nil(); + } else if (value.isBool()) { + pk.pack(value.getBool()); + } else if (value.isNumber()) { + double d = value.getNumber(); + // Doubles can exactly represent integers up to 2^53. Encode exact + // integers as msgpack int/uint (matching @msgpack/msgpack JS behavior) + // so Go's decoder sees integer types, not float64. + if (d == std::floor(d) && std::isfinite(d)) { + if (d >= 0) { + pk.pack(static_cast(d)); + } else { + pk.pack(static_cast(d)); + } + } else { + pk.pack(d); + } + } else if (value.isString()) { + auto str = value.getString(runtime).utf8(runtime); + pk.pack(str); + } else if (value.isObject()) { + auto obj = value.getObject(runtime); + if (obj.isArrayBuffer(runtime)) { + auto buf = obj.getArrayBuffer(runtime); + pk.pack_bin(static_cast(buf.size(runtime))); + pk.pack_bin_body(reinterpret_cast(buf.data(runtime)), + static_cast(buf.size(runtime))); + } else if (obj.isArray(runtime)) { + auto arr = obj.getArray(runtime); + auto len = arr.size(runtime); + pk.pack_array(static_cast(len)); + for (size_t i = 0; i < len; ++i) { + convertJSIToMP(runtime, arr.getValueAtIndex(runtime, i), &pk); + } + } else { + // Check for Uint8Array: has "byteLength" and "buffer" properties + // where "buffer" is an ArrayBuffer + auto byteLengthProp = obj.getProperty(runtime, "byteLength"); + if (byteLengthProp.isNumber()) { + auto bufferProp = obj.getProperty(runtime, "buffer"); + if (bufferProp.isObject()) { + auto bufferObj = bufferProp.asObject(runtime); + if (bufferObj.isArrayBuffer(runtime)) { + // This is a TypedArray (Uint8Array) — encode as BIN + auto arrayBuf = bufferObj.getArrayBuffer(runtime); + auto byteOffset = obj.getProperty(runtime, "byteOffset"); + size_t offset = byteOffset.isNumber() + ? static_cast(byteOffset.getNumber()) + : 0; + size_t length = static_cast(byteLengthProp.getNumber()); + pk.pack_bin(static_cast(length)); + pk.pack_bin_body( + reinterpret_cast(arrayBuf.data(runtime)) + offset, + static_cast(length)); + return; } - } else { - break; } } + // Regular object — encode as MAP + auto names = obj.getPropertyNames(runtime); + auto len = names.size(runtime); + pk.pack_map(static_cast(len)); + for (size_t i = 0; i < len; ++i) { + auto name = names.getValueAtIndex(runtime, i).getString(runtime); + auto nameStr = name.utf8(runtime); + pk.pack(nameStr); + convertJSIToMP(runtime, obj.getProperty(runtime, name), &pk); + } } - return values; - } catch (const std::exception &e) { - throw std::runtime_error("Error in PrepRpcOnJS: " + - std::string(e.what())); - } catch (...) { - throw std::runtime_error("Unknown error in PrepRpcOnJS"); } } -void RpcOnJS(Runtime &runtime, ShareValues values, void (*err_callback)(const std::string &err)) { - try { - if (isTornDown.load()) { - return; - } +void KBBridge::packAndSend(Runtime &runtime, const Value &value) { + msgpack::sbuffer sbuf; + msgpack::packer pk(&sbuf); + convertJSIToMP(runtime, value, &pk); - // non-owning - static Function* cachedRpcOnJs = nullptr; - static Runtime* cachedRuntime = nullptr; + // Write framed: [length prefix][content] + msgpack::sbuffer frameBuf; + msgpack::packer framePk(&frameBuf); + framePk.pack(static_cast(sbuf.size())); - if (cachedRuntime != &runtime) { - cachedRpcOnJs = nullptr; - cachedRuntime = &runtime; - } + std::vector combined(frameBuf.size() + sbuf.size()); + std::memcpy(combined.data(), frameBuf.data(), frameBuf.size()); + std::memcpy(combined.data() + frameBuf.size(), sbuf.data(), sbuf.size()); - if (!cachedRpcOnJs) { - try { - auto func = runtime.global().getPropertyAsFunction(runtime, "rpcOnJs"); - cachedRpcOnJs = new Function(std::move(func)); - } catch (...) { - err_callback("Failed to get rpcOnJs function"); - throw std::runtime_error("Failed to get rpcOnJs function:"); - return; + writeToGo_(combined.data(), combined.size()); +} + +void KBBridge::install( + Runtime &runtime, + std::shared_ptr callInvoker, + std::function writeToGo, + std::function onError) { + callInvoker_ = std::move(callInvoker); + onError_ = std::move(onError); + writeToGo_ = std::move(writeToGo); + mp_ = std::make_unique(); + + auto rpcOnGo = Function::createFromHostFunction( + runtime, PropNameID::forAscii(runtime, "rpcOnGo"), 1, + [self = shared_from_this()](Runtime &runtime, const Value &thisValue, + const Value *arguments, + size_t count) -> Value { + try { + self->packAndSend(runtime, arguments[0]); + return Value(true); + } catch (const std::exception &e) { + throw std::runtime_error("Error in rpcOnGo: " + + std::string(e.what())); + } catch (...) { + throw std::runtime_error("Unknown error in rpcOnGo"); + } + }); + + runtime.global().setProperty(runtime, "rpcOnGo", std::move(rpcOnGo)); + + // HostObject that calls teardown when the JS runtime is destroyed + class KBTearDownSimple : public jsi::HostObject { + public: + KBTearDownSimple(std::weak_ptr bridge) : bridge_(bridge) { + if (auto b = bridge_.lock()) { + b->tearup(); + } + } + ~KBTearDownSimple() override { + if (auto b = bridge_.lock()) { + b->teardown(); } } + Value get(Runtime &, const PropNameID &) override { + return Value::undefined(); + } + void set(Runtime &, const PropNameID &, const Value &) override {} + std::vector getPropertyNames(Runtime &) override { return {}; } + + private: + std::weak_ptr bridge_; + }; + + runtime.global().setProperty( + runtime, "kbTeardown", + Object::createFromHostObject( + runtime, + std::make_shared(shared_from_this()))); +} - for (auto &result : *values) { - msgpack::object obj(result.get()); - Value value = convertMPToJSI(runtime, obj); - if (isTornDown.load()) { - return; +void KBBridge::onDataFromGo(uint8_t *data, int size) { + if (isTornDown_.load() || size <= 0) { + return; + } + + try { + auto values = std::make_shared>(); + mp_->unpacker.reserve_buffer(size); + std::copy(data, data + size, mp_->unpacker.buffer()); + mp_->unpacker.buffer_consumed(size); + while (true) { + msgpack::object_handle result; + if (mp_->unpacker.next(result)) { + if (readState_ == ReadState::needSize) { + readState_ = ReadState::needContent; + } else { + values->push_back(std::move(result)); + readState_ = ReadState::needSize; + } + } else { + break; } - Function rpcOnJs = runtime.global().getPropertyAsFunction(runtime, "rpcOnJs"); - rpcOnJs.call(runtime, std::move(value), 1); } + + if (values->empty()) { + return; + } + + auto self = shared_from_this(); + callInvoker_->invokeAsync([values, self](jsi::Runtime &runtime) { + try { + if (self->isTornDown_.load()) { + return; + } + + self->resetCaches(runtime); + if (!self->cachedRpcOnJs_) { + try { + auto func = + runtime.global().getPropertyAsFunction(runtime, "rpcOnJs"); + self->cachedRpcOnJs_ = + std::make_unique(std::move(func)); + } catch (...) { + if (self->onError_) { + self->onError_("Failed to get rpcOnJs function"); + } + return; + } + } + + if (values->size() == 1) { + // Single message: pass directly (no array wrapper) + msgpack::object obj((*values)[0].get()); + Value value = self->convertMPToJSI(runtime, &obj); + if (self->isTornDown_.load()) { + return; + } + self->cachedRpcOnJs_->call(runtime, std::move(value), + jsi::Value(1)); + } else { + // Multiple messages: batch into array, pass count + jsi::Array arr(runtime, values->size()); + for (size_t i = 0; i < values->size(); ++i) { + msgpack::object obj((*values)[i].get()); + arr.setValueAtIndex(runtime, i, + self->convertMPToJSI(runtime, &obj)); + } + if (self->isTornDown_.load()) { + return; + } + self->cachedRpcOnJs_->call( + runtime, std::move(arr), + jsi::Value(static_cast(values->size()))); + } + } catch (const std::exception &e) { + if (self->onError_) { + self->onError_(e.what()); + } + } catch (...) { + if (self->onError_) { + self->onError_("unknown error in onDataFromGo JS callback"); + } + } + }); } catch (const std::exception &e) { - err_callback(e.what()); - throw std::runtime_error("Error in RpcOnJS: " + std::string(e.what())); + if (onError_) { + onError_(std::string("Error in onDataFromGo: ") + e.what()); + } } catch (...) { - err_callback("unknown error"); - throw std::runtime_error("Unknown error in RpcOnJS"); + if (onError_) { + onError_("Unknown error in onDataFromGo"); + } } } + } // namespace kb diff --git a/rnmodules/react-native-kb/cpp/react-native-kb.h b/rnmodules/react-native-kb/cpp/react-native-kb.h index e3a2dc67d41f..6706b7d2353a 100644 --- a/rnmodules/react-native-kb/cpp/react-native-kb.h +++ b/rnmodules/react-native-kb/cpp/react-native-kb.h @@ -1,22 +1,55 @@ #pragma once +#include +#include #include #include +#include #include #include -#include +#include namespace kb { -facebook::jsi::Value RpcOnGo(facebook::jsi::Runtime &runtime, - const facebook::jsi::Value &thisValue, - const facebook::jsi::Value *arguments, - size_t count, - void (*callback)(void *ptr, size_t size)); - -typedef std::shared_ptr> ShareValues; -ShareValues PrepRpcOnJS(facebook::jsi::Runtime &runtime, uint8_t *data, - int size); -void RpcOnJS(facebook::jsi::Runtime &runtime, ShareValues values, - void (*err_callback)(const std::string &err)); -void Teardown(); -void Tearup(); + +class KBBridge : public std::enable_shared_from_this { +public: + KBBridge(); + ~KBBridge(); + KBBridge(const KBBridge &) = delete; + KBBridge &operator=(const KBBridge &) = delete; + KBBridge(KBBridge &&) = delete; + KBBridge &operator=(KBBridge &&) = delete; + void install(facebook::jsi::Runtime &runtime, + std::shared_ptr callInvoker, + std::function writeToGo, + std::function onError); + + void onDataFromGo(uint8_t *data, int size); + void teardown(); + void tearup(); + +private: + std::shared_ptr callInvoker_; + std::function onError_; + std::atomic isTornDown_{false}; + + enum class ReadState { needSize, needContent }; + ReadState readState_ = ReadState::needSize; + + struct MsgpackState; + std::unique_ptr mp_; + + std::unique_ptr cachedUint8ArrayCtor_; + std::unique_ptr cachedRpcOnJs_; + facebook::jsi::Runtime *cachedRuntime_ = nullptr; + std::function writeToGo_; + + void resetCaches(facebook::jsi::Runtime &runtime); + facebook::jsi::Value convertMPToJSI(facebook::jsi::Runtime &runtime, + void *mpObj); + void convertJSIToMP(facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value, void *packer); + void packAndSend(facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value); +}; + } // namespace kb diff --git a/rnmodules/react-native-kb/ios/KBJSScheduler.cpp b/rnmodules/react-native-kb/ios/KBJSScheduler.cpp deleted file mode 100644 index 6898798e006f..000000000000 --- a/rnmodules/react-native-kb/ios/KBJSScheduler.cpp +++ /dev/null @@ -1,26 +0,0 @@ -// https://github.com/software-mansion/react-native-reanimated/blob/main/Common/cpp/Tools/ -#include "./KBJSScheduler.h" -using namespace facebook; -using namespace react; - -KBJSScheduler::KBJSScheduler( jsi::Runtime &rnRuntime, const std::shared_ptr &jsCallInvoker) - : scheduleOnJS([&](KBJob job) { - jsCallInvoker_->invokeAsync( - [job = std::move(job), &rt = rnRuntime_] { job(rt); }); - }), - rnRuntime_(rnRuntime), - jsCallInvoker_(jsCallInvoker) {} - -// With `runtimeExecutor`. -KBJSScheduler::KBJSScheduler( jsi::Runtime &rnRuntime, RuntimeExecutor runtimeExecutor) - : scheduleOnJS([&](KBJob job) { - runtimeExecutor_( - [job = std::move(job)](jsi::Runtime &runtime) { job(runtime); }); - }), - rnRuntime_(rnRuntime), - runtimeExecutor_(runtimeExecutor) {} - -const std::shared_ptr KBJSScheduler::getJSCallInvoker() const { - assert( jsCallInvoker_ != nullptr && " Expected jsCallInvoker, got nullptr instead."); - return jsCallInvoker_; -} diff --git a/rnmodules/react-native-kb/ios/KBJSScheduler.h b/rnmodules/react-native-kb/ios/KBJSScheduler.h deleted file mode 100644 index 2166c6056697..000000000000 --- a/rnmodules/react-native-kb/ios/KBJSScheduler.h +++ /dev/null @@ -1,29 +0,0 @@ -// https://github.com/software-mansion/react-native-reanimated/blob/main/Common/cpp/Tools/ -#pragma once - -#include -#include -#include - -#include -#include - -using namespace facebook; -using namespace react; - -using KBJob = std::function; - -class KBJSScheduler { - public: - // With `jsCallInvoker`. - explicit KBJSScheduler( jsi::Runtime &rnRuntime, const std::shared_ptr &jsCallInvoker); - // With `runtimeExecutor`. - explicit KBJSScheduler( jsi::Runtime &rnRuntime, RuntimeExecutor runtimeExecutor); - const std::function scheduleOnJS = nullptr; - const std::shared_ptr getJSCallInvoker() const; - - protected: - jsi::Runtime &rnRuntime_; - RuntimeExecutor runtimeExecutor_ = nullptr; - const std::shared_ptr jsCallInvoker_ = nullptr; -}; diff --git a/rnmodules/react-native-kb/ios/Kb.h b/rnmodules/react-native-kb/ios/Kb.h index 8c919fbfa7dc..6a0c4ee019c1 100644 --- a/rnmodules/react-native-kb/ios/Kb.h +++ b/rnmodules/react-native-kb/ios/Kb.h @@ -8,7 +8,8 @@ #ifdef RCT_NEW_ARCH_ENABLED #import #import -@interface Kb : RCTEventEmitter +#import +@interface Kb : RCTEventEmitter @end #else #endif // RCT_NEW_ARCH_ENABLED diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 6bb4193f51ea..94ace5701cef 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -1,11 +1,6 @@ #import "Kb.h" #import "Keybasego.h" -#import -#import #import -#import -#import -#import #import #import #import @@ -15,7 +10,6 @@ #import #import #import -#import "./KBJSScheduler.h" #import "RNKbSpec.h" #import @@ -24,23 +18,6 @@ using namespace std; using namespace kb; -// used to keep track of objects getting destroyed on the js side -class KBTearDown : public jsi::HostObject { -public: - KBTearDown() { Tearup(); } - virtual ~KBTearDown() { - NSLog(@"KBTeardown!!!"); - Teardown(); - } - virtual jsi::Value get(jsi::Runtime &, const jsi::PropNameID &name) { - return jsi::Value::undefined(); - } - virtual void set(jsi::Runtime &, const jsi::PropNameID &name, const jsi::Value &value) {} - virtual std::vector getPropertyNames(jsi::Runtime &rt) { - return {}; - } -}; - @implementation FsPathsHolder @synthesize fsPaths; @@ -75,32 +52,14 @@ - (void)dealloc { static NSString *kbStoredDeviceToken = nil; static NSDictionary *kbInitialNotification = nil; -@interface RCTBridge (JSIRuntime) -- (void *)runtime; -@end - -@interface RCTBridge (RCTTurboModule) -- (std::shared_ptr)jsCallInvoker; -- (void)_tryAndHandleError:(dispatch_block_t)block; -@end - -@interface RCTBridge () -- (JSGlobalContextRef)jsContextRef; -- (void *)runtime; -- (void)dispatchBlock:(dispatch_block_t)block queue:(dispatch_queue_t)queue; -@end - @interface Kb () @property dispatch_queue_t readQueue; @end -@implementation Kb - -jsi::Runtime *_jsRuntime; -std::shared_ptr jsScheduler; - -// sanity check the runtime isn't out of sync due to reload etc -void *currentRuntime = nil; +@implementation Kb { + std::shared_ptr kbBridge_; + BOOL isInvalidated_; +} RCT_EXPORT_MODULE() @@ -111,6 +70,7 @@ + (BOOL)requiresMainQueueSetup { - (instancetype)init { self = [super init]; kbSharedInstance = self; + isInvalidated_ = NO; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleHardwareKeyPressed:) name:@"hardwareKeyPressed" @@ -125,13 +85,13 @@ + (void)swizzleUITextViewPaste { Class cls = [UITextView class]; SEL originalPaste = @selector(paste:); - SEL swizzledPaste = @selector(kb_paste:); + SEL swizzledPaste = NSSelectorFromString(@"kb_paste:"); Method originalPasteMethod = class_getInstanceMethod(cls, originalPaste); Method swizzledPasteMethod = class_getInstanceMethod(cls, swizzledPaste); method_exchangeImplementations(originalPasteMethod, swizzledPasteMethod); SEL originalCanPerform = @selector(canPerformAction:withSender:); - SEL swizzledCanPerform = @selector(kb_canPerformAction:withSender:); + SEL swizzledCanPerform = NSSelectorFromString(@"kb_canPerformAction:withSender:"); Method originalCanPerformMethod = class_getInstanceMethod(cls, originalCanPerform); Method swizzledCanPerformMethod = class_getInstanceMethod(cls, swizzledCanPerform); method_exchangeImplementations(originalCanPerformMethod, swizzledCanPerformMethod); @@ -161,12 +121,13 @@ + (void)handlePastedImages:(NSArray *)images { - (void)invalidate { [[NSNotificationCenter defaultCenter] removeObserver:self]; - currentRuntime = nil; - _jsRuntime = nil; + isInvalidated_ = YES; kbPasteImageEnabled = NO; + if (kbBridge_) { + kbBridge_->teardown(); + kbBridge_.reset(); + } [super invalidate]; - Teardown(); - self.bridge = nil; self.readQueue = nil; NSError *error = nil; KeybaseReset(&error); @@ -186,45 +147,30 @@ - (void)invalidate { return std::make_shared(params); } -- (void)sendToJS:(NSData *)data { - __weak __typeof__(self) weakSelf = self; - - jsScheduler->scheduleOnJS([data, weakSelf](jsi::Runtime &jsiRuntime) { - __typeof__(self) strongSelf = weakSelf; - if (!strongSelf) { - NSLog(@"Failed to find self in sendToJS invokeAsync!!!"); - return; - } - auto jsRuntimePtr = [strongSelf javaScriptRuntimePointer]; - if (!jsRuntimePtr) { - NSLog(@"Failed to find jsi in sendToJS invokeAsync!!!"); - return; - } - - int size = (int)[data length]; - if (size <= 0) { - NSLog(@"Invalid data size in sendToJS: %d", size); - return; - } - try { - auto values = PrepRpcOnJS(jsiRuntime, (uint8_t *)[data bytes], size); - RpcOnJS(jsiRuntime, values, [](const std::string &err) { - KeybaseLogToService([NSString - stringWithFormat:@"dNativeLogger: [%f,\"jsi rpconjs error: %@\"]", - [[NSDate date] timeIntervalSince1970] * 1000, - [NSString stringWithUTF8String:err.c_str()]]); +// RCTTurboModuleWithJSIBindings — called automatically by RN when the module loads +- (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime + callInvoker:(const std::shared_ptr &)callInvoker { + kbBridge_ = std::make_shared(); + kbBridge_->install(runtime, callInvoker, + // writeToGo callback + [](void *ptr, size_t size) { + NSData *data = [NSData dataWithBytesNoCopy:ptr length:size freeWhenDone:NO]; + NSError *error = nil; + KeybaseWriteArr(data, &error); + if (error) { + NSLog(@"Error writing data: %@", error); + } + }, + // error callback + [](const std::string &err) { + KeybaseLogToService([NSString + stringWithFormat:@"dNativeLogger: [%f,\"jsi error: %s\"]", + [[NSDate date] timeIntervalSince1970] * 1000, + err.c_str()]); }); - } catch (const std::exception &e) { - NSLog(@"Exception in sendToJS msgpack processing: %s", e.what()); - KeybaseLogToService([NSString - stringWithFormat:@"dNativeLogger: [%f,\"sendToJS unknown exception\"]", - [[NSDate date] timeIntervalSince1970] * 1000]); - } - }); -} -- (jsi::Runtime *)javaScriptRuntimePointer { - return _jsRuntime; + KeybaseLogToService([NSString stringWithFormat:@"dNativeLogger: [%f,\"jsi install success (via installJSIBindings)\"]", + [[NSDate date] timeIntervalSince1970] * 1000]); } // from react-native-localize @@ -319,16 +265,15 @@ - (NSDictionary *)getConstants { } } +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(install) { + // No-op: JSI bindings are now installed via installJSIBindingsWithRuntime:callInvoker: + return @YES; +} + RCT_EXPORT_METHOD(notifyJSReady) { __weak __typeof__(self) weakSelf = self; dispatch_async(dispatch_get_main_queue(), ^{ - // Setup infrastructure - [[NSNotificationCenter defaultCenter] - addObserver:self - selector:@selector(engineReset) - name:RCTJavaScriptWillStartLoadingNotification - object:nil]; self.readQueue = dispatch_queue_create("go_bridge_queue_read", DISPATCH_QUEUE_SERIAL); // Signal to Go that JS is ready @@ -340,8 +285,8 @@ - (NSDictionary *)getConstants { while (true) { { __typeof__(self) strongSelf = weakSelf; - if (!strongSelf || !strongSelf.bridge) { - NSLog(@"Bridge dead, bailing from ReadArr loop"); + if (!strongSelf || strongSelf->isInvalidated_) { + NSLog(@"Module invalidated, bailing from ReadArr loop"); return; } } @@ -352,8 +297,8 @@ - (NSDictionary *)getConstants { NSLog(@"Error reading data: %@", error); } else if (data) { __typeof__(self) strongSelf = weakSelf; - if (strongSelf) { - [strongSelf sendToJS:data]; + if (strongSelf && strongSelf->kbBridge_) { + strongSelf->kbBridge_->onDataFromGo((uint8_t *)[data bytes], (int)[data length]); } } } @@ -363,43 +308,13 @@ - (NSDictionary *)getConstants { @synthesize callInvoker = _callInvoker; -RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(install) { - RCTCxxBridge *cxxBridge = (RCTCxxBridge *)self.bridge; - _jsRuntime = (jsi::Runtime *)cxxBridge.runtime; - auto &rnRuntime = *(jsi::Runtime *)cxxBridge.runtime; - jsScheduler = std::make_shared(rnRuntime, _callInvoker.callInvoker); - - // stash the current runtime to keep in sync - auto rpcOnGoWrap = [](Runtime &runtime, const Value &thisValue, const Value *arguments, size_t count) -> Value { - return RpcOnGo(runtime, thisValue, arguments, count, [](void *ptr, size_t size) { - NSData *result = [NSData dataWithBytesNoCopy:ptr length:size freeWhenDone:NO]; - NSError *error = nil; - KeybaseWriteArr(result, &error); - if (error) { - NSLog(@"Error writing data: %@", error); - } - }); - }; - - KeybaseLogToService([NSString stringWithFormat:@"dNativeLogger: [%f,\"jsi install success\"]", - [[NSDate date] timeIntervalSince1970] * 1000]); - - _jsRuntime->global().setProperty(*_jsRuntime, "rpcOnGo", - Function::createFromHostFunction(*_jsRuntime, PropNameID::forAscii(*_jsRuntime, "rpcOnGo"), 1, std::move(rpcOnGoWrap))); - - // register a global so we get notified when the runtime is killed so we can - // cleanup - _jsRuntime->global().setProperty(*_jsRuntime, "kbTeardown", jsi::Object::createFromHostObject(*_jsRuntime, std::make_shared())); - return @YES; -} - RCT_EXPORT_METHOD(getDefaultCountryCode : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) { - CTTelephonyNetworkInfo *network_Info = [CTTelephonyNetworkInfo new]; - // TODO this will stop working at some point - CTCarrier *carrier = network_Info.subscriberCellularProvider; - resolve(carrier.isoCountryCode); + // CTCarrier was removed in iOS 16.4 with no replacement. + // Use the locale's region code instead — good enough for phone number formatting. + NSString *countryCode = [[NSLocale currentLocale] countryCode]; + resolve(countryCode ?: @""); } RCT_EXPORT_METHOD(logSend:(NSString *)status feedback:(NSString *)feedback sendLogs:(BOOL)sendLogs sendMaxBytes:(BOOL)sendMaxBytes traceDir:(NSString *)traceDir cpuProfileDir:(NSString *)cpuProfileDir resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { @@ -489,33 +404,33 @@ - (NSDictionary *)getConstants { FsPathsHolder *holder = [FsPathsHolder sharedFsPathsHolder]; NSDictionary *fsPaths = holder.fsPaths; NSString *logFilePath = fsPaths[@"logFile"]; - + if (!logFilePath || logFilePath.length == 0) { resolve(@YES); return; } - + NSString *logDir = [logFilePath stringByDeletingLastPathComponent]; NSFileManager *fm = [NSFileManager defaultManager]; - + if (![fm fileExistsAtPath:logDir]) { resolve(@YES); return; } - + NSError *error = nil; NSArray *files = [fm contentsOfDirectoryAtPath:logDir error:&error]; - + if (error) { NSLog(@"Error listing log directory: %@", error.localizedDescription); resolve(@YES); return; } - + for (NSString *fileName in files) { NSString *filePath = [logDir stringByAppendingPathComponent:fileName]; NSFileHandle *fileHandle = [NSFileHandle fileHandleForWritingAtPath:filePath]; - + if (fileHandle) { @try { [fileHandle truncateFileAtOffset:0]; @@ -526,7 +441,7 @@ - (NSDictionary *)getConstants { } } } - + resolve(@YES); } @@ -584,10 +499,6 @@ + (void)setInitialNotification:(NSDictionary *)notification { } + (void)emitPushNotification:(NSDictionary *)notification { - NSString *type = notification[@"type"] ?: @"unknown"; - NSString *convID = notification[@"convID"] ?: notification[@"c"] ?: @"unknown"; - NSNumber *userInteraction = notification[@"userInteraction"]; - if (kbSharedInstance) { [kbSharedInstance sendEventWithName:@"onPushNotification" body:notification]; NSLog(@"Kb.emitPushNotification: sent event 'onPushNotification' to JS"); @@ -616,7 +527,7 @@ - (NSNumber *)androidSetSecureFlagSetting:(BOOL)s {return @-1;} - (NSNumber *)androidShare:(NSString *)text mimeType:(NSString *)mimeType {return @-1;} - (NSNumber *)androidShareText:(NSString *)text mimeType:(NSString *)mimeType {return @-1;} - (NSString *)androidGetRegistrationToken {return @"";} -- (void)androidAddCompleteDownload:(/*JS::NativeKb::SpecAndroidAddCompleteDownloadO &*/id)o resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {} +- (void)androidAddCompleteDownload:(JS::NativeKb::SpecAndroidAddCompleteDownloadO &)o resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {} - (void)androidAppColorSchemeChanged:(NSString *)mode {} - (void)androidCheckPushPermissions:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {} - (void)androidGetRegistrationToken:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {} diff --git a/rnmodules/react-native-kb/react-native-kb.podspec b/rnmodules/react-native-kb/react-native-kb.podspec index fd69e6c875ef..5091221340d4 100644 --- a/rnmodules/react-native-kb/react-native-kb.podspec +++ b/rnmodules/react-native-kb/react-native-kb.podspec @@ -26,7 +26,7 @@ Pod::Spec.new do |s| # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79. if respond_to?(:install_modules_dependencies, true) s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\" $(PODS_ROOT)/../../node_modules/msgpack-cxx-6.1.0/include $(PODS_ROOT)/../keybasego.xcframework/ios-arm64/Keybasego.framework/Headers \"$(PODS_CONFIGURATION_BUILD_DIR)/KBCommon\"", + "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\" $(PODS_ROOT)/../../node_modules/msgpack-cxx-7.0.0/include $(PODS_ROOT)/../keybasego.xcframework/ios-arm64/Keybasego.framework/Headers \"$(PODS_CONFIGURATION_BUILD_DIR)/KBCommon\"", "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -DMSGPACK_NO_BOOST=1", "CLANG_CXX_LANGUAGE_STANDARD" => "c++17" } @@ -38,7 +38,7 @@ Pod::Spec.new do |s| if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1" s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\" $(PODS_ROOT)/../../node_modules/msgpack-cxx-6.1.0/include $(PODS_ROOT)/../keybasego.xcframework/ios-arm64/Keybasego.framework/Headers \"$(PODS_CONFIGURATION_BUILD_DIR)/KBCommon\"", + "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\" $(PODS_ROOT)/../../node_modules/msgpack-cxx-7.0.0/include $(PODS_ROOT)/../keybasego.xcframework/ios-arm64/Keybasego.framework/Headers \"$(PODS_CONFIGURATION_BUILD_DIR)/KBCommon\"", "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -DMSGPACK_NO_BOOST=1", "CLANG_CXX_LANGUAGE_STANDARD" => "c++17" } @@ -49,7 +49,7 @@ Pod::Spec.new do |s| s.dependency "ReactCommon/turbomodule/core" else s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\" $(PODS_ROOT)/../../node_modules/msgpack-cxx-6.1.0/include $(PODS_ROOT)/../keybasego.xcframework/ios-arm64/Keybasego.framework/Headers \"$(PODS_CONFIGURATION_BUILD_DIR)/KBCommon\"", + "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\" $(PODS_ROOT)/../../node_modules/msgpack-cxx-7.0.0/include $(PODS_ROOT)/../keybasego.xcframework/ios-arm64/Keybasego.framework/Headers \"$(PODS_CONFIGURATION_BUILD_DIR)/KBCommon\"", "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -DMSGPACK_NO_BOOST=1", "CLANG_CXX_LANGUAGE_STANDARD" => "c++17" } diff --git a/rnmodules/react-native-kb/src/index.tsx b/rnmodules/react-native-kb/src/index.tsx index 964365198291..0db98f074070 100644 --- a/rnmodules/react-native-kb/src/index.tsx +++ b/rnmodules/react-native-kb/src/index.tsx @@ -1,25 +1,7 @@ -import {NativeModules, Platform, NativeEventEmitter} from 'react-native' +import {Platform, NativeEventEmitter} from 'react-native' +import KbNative from './NativeKb' -const LINKING_ERROR = - `The package 'react-native-kb' doesn't seem to be linked. Make sure: \n\n` + - Platform.select({ios: "- You have run 'pod install'\n", default: ''}) + - '- You rebuilt the app after installing the package\n' + - '- You are not using Expo Go\n' - -const isTurboModuleEnabled = global.__turboModuleProxy != null - -const KbModule = isTurboModuleEnabled ? require('./NativeKb').default : NativeModules['Kb'] - -const Kb = KbModule - ? KbModule - : new Proxy( - {}, - { - get() { - throw new Error(LINKING_ERROR) - }, - } - ) +const Kb = KbNative export const getDefaultCountryCode = (): Promise => { return Kb.getDefaultCountryCode() @@ -37,7 +19,7 @@ export const logSend = ( } export const install = () => { - Kb.install() + // No-op: JSI bindings are now installed automatically via TurboModuleWithJSIBindings } export const iosGetHasShownPushPrompt = (): Promise => { if (Platform.OS === 'ios') { diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/ChatBroadcastReceiver.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/ChatBroadcastReceiver.kt index e7ce43df86cc..afb2f3a7dd03 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/ChatBroadcastReceiver.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/ChatBroadcastReceiver.kt @@ -5,9 +5,7 @@ import android.app.RemoteInput import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.os.Build import android.os.Bundle -import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import io.keybase.ossifrage.MainActivity.Companion.setupKBRuntime @@ -15,16 +13,14 @@ import io.keybase.ossifrage.modules.NativeLogger import keybase.Keybase class ChatBroadcastReceiver : BroadcastReceiver() { - @RequiresApi(api = Build.VERSION_CODES.KITKAT_WATCH) private fun getMessageText(intent: Intent): String? { val remoteInput = RemoteInput.getResultsFromIntent(intent) return remoteInput?.getCharSequence(KEY_TEXT_REPLY)?.toString() } - @RequiresApi(api = Build.VERSION_CODES.KITKAT_WATCH) override fun onReceive(context: Context, intent: Intent) { setupKBRuntime(context, false) - val convData = ConvData(intent) + val convData = ConvData.fromIntent(intent) val openConv = intent.getParcelableExtra("openConvPendingIntent") val repliedNotification = NotificationCompat.Builder(context, KeybasePushNotificationListenerService.CHAT_CHANNEL_ID) .setContentIntent(openConv) @@ -53,30 +49,15 @@ class ChatBroadcastReceiver : BroadcastReceiver() { } companion object { - @JvmField - var KEY_TEXT_REPLY = "key_text_reply" + const val KEY_TEXT_REPLY = "key_text_reply" } } -internal class ConvData { - @JvmField - var convID: String? - var tlfName: String? - var lastMsgId: Long - - constructor(convId: String?, tlfName: String?, lastMsgId: Long) { - convID = convId - this.tlfName = tlfName - this.lastMsgId = lastMsgId - } - - constructor(intent: Intent) { - val data = intent.getBundleExtra("ConvData") - convID = data!!.getString("convID") - tlfName = data.getString("tlfName") - lastMsgId = data.getLong("lastMsgId") - } - +internal data class ConvData( + @JvmField val convID: String?, + val tlfName: String?, + val lastMsgId: Long +) { fun intoIntent(context: Context?): Intent { val data = Bundle() data.putString("convID", convID) @@ -86,4 +67,15 @@ internal class ConvData { intent.putExtra("ConvData", data) return intent } + + companion object { + fun fromIntent(intent: Intent): ConvData { + val data = intent.getBundleExtra("ConvData")!! + return ConvData( + convID = data.getString("convID"), + tlfName = data.getString("tlfName"), + lastMsgId = data.getLong("lastMsgId") + ) + } + } } diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/CustomBitmapMemoryCacheParamsSupplier.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/CustomBitmapMemoryCacheParamsSupplier.kt index a7197ca9c4da..734810b2a6c9 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/CustomBitmapMemoryCacheParamsSupplier.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/CustomBitmapMemoryCacheParamsSupplier.kt @@ -2,7 +2,6 @@ package io.keybase.ossifrage import android.app.ActivityManager import android.content.Context -import android.os.Build import com.facebook.common.internal.Supplier import com.facebook.common.util.ByteConstants import com.facebook.imagepipeline.cache.MemoryCacheParams @@ -27,12 +26,10 @@ class CustomBitmapMemoryCacheParamsSupplier(context: Context) : Supplier 4 * ByteConstants.MB + maxMemory < 64 * ByteConstants.MB -> 6 * ByteConstants.MB + else -> maxMemory / CACHE_DIVISION } } diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KBInstallReferrerListener.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KBInstallReferrerListener.kt index 6e3644f6f608..030b077e0644 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KBInstallReferrerListener.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KBInstallReferrerListener.kt @@ -36,17 +36,17 @@ class KBInstallReferrerListener internal constructor(_context: Context) : Native override fun onInstallReferrerSetupFinished(responseCode: Int) { Log.e("KBIR", "KBInstallReferrerListener#onInstallReferrerSetupFinished: got code $responseCode") - executor.execute(Runnable { + executor.execute { when (responseCode) { InstallReferrerClient.InstallReferrerResponse.OK -> { // Connection established handleReferrerResponseOK() - return@Runnable + return@execute } InstallReferrerClient.InstallReferrerResponse.SERVICE_DISCONNECTED -> { reconnect() - return@Runnable + return@execute } InstallReferrerClient.InstallReferrerResponse.FEATURE_NOT_SUPPORTED, InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE, InstallReferrerClient.InstallReferrerResponse.DEVELOPER_ERROR -> // other issues, can't do much here.... @@ -54,7 +54,7 @@ class KBInstallReferrerListener internal constructor(_context: Context) : Native else -> callback!!.callbackWithString("") } - }) + } } private fun handleReferrerResponseOK() { diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt index 2d071a144bfa..1ff93094f718 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt @@ -11,35 +11,26 @@ import android.graphics.PorterDuff import android.graphics.PorterDuffXfermode import android.graphics.Rect import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper -import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import androidx.core.app.RemoteInput import androidx.core.graphics.drawable.IconCompat -import io.keybase.ossifrage.MainActivity import keybase.ChatNotification import keybase.PushNotifier import java.io.BufferedInputStream import java.io.IOException -import java.io.InputStream import java.net.HttpURLConnection import java.net.URL -import android.util.Log class KBPushNotifier internal constructor(private val context: Context, private val bundle: Bundle) : PushNotifier { private var convMsgCache: SmallMsgRingBuffer? = null private fun buildStyle(person: Person): NotificationCompat.MessagingStyle { val style = NotificationCompat.MessagingStyle(person) - if (convMsgCache != null) { - for (msg in convMsgCache!!.summary()) { - style.addMessage(msg) - } - } + convMsgCache?.summary()?.forEach { style.addMessage(it) } return style } @@ -79,7 +70,6 @@ class KBPushNotifier internal constructor(private val context: Context, private } } - @RequiresApi(api = Build.VERSION_CODES.KITKAT_WATCH) private fun newReplyAction(context: Context, convData: ConvData, openConv: PendingIntent): NotificationCompat.Action { val replyLabel = "Reply" val remoteInput = RemoteInput.Builder(ChatBroadcastReceiver.KEY_TEXT_REPLY) @@ -131,33 +121,26 @@ class KBPushNotifier internal constructor(private val context: Context, private notificationDefaults = notificationDefaults or NotificationCompat.DEFAULT_SOUND } else { val soundResource = filenameResourceName(chatNotification.soundName) - val soundUriStr = "android.resource://" + context.packageName + "/raw/" + soundResource + val soundUriStr = "android.resource://${context.packageName}/raw/$soundResource" val soundUri = Uri.parse(soundUriStr) builder.setSound(soundUri) } builder.setDefaults(notificationDefaults) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) { - builder.addAction(newReplyAction(context, convData, pending_intent)) - } + builder.addAction(newReplyAction(context, convData, pending_intent)) val msg = chatNotification.message val from = msg.from val personBuilder = Person.Builder() .setName(from?.keybaseUsername ?: "") .setBot(from?.isBot ?: false) val avatarUri = chatNotification.message.from?.keybaseAvatar - if (avatarUri != null && avatarUri.isNotEmpty()) { - val icon = getKeybaseAvatar(avatarUri) - if (icon != null) { - personBuilder.setIcon(icon) - } - } + avatarUri?.takeIf { it.isNotEmpty() }?.let { getKeybaseAvatar(it) }?.let { personBuilder.setIcon(it) } val fromPerson = personBuilder.build() - if (convMsgCache != null) { + convMsgCache?.let { cache -> var msgText = if (chatNotification.isPlaintext) chatNotification.message.plaintext else "" if (msgText.isEmpty()) { msgText = chatNotification.message.serverMessage } - convMsgCache!!.add(NotificationCompat.MessagingStyle.Message(msgText, msg.at, fromPerson)) + cache.add(NotificationCompat.MessagingStyle.Message(msgText, msg.at, fromPerson)) } val style = buildStyle(fromPerson) style.setConversationTitle(chatNotification.conversationName ?: "") diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KBReactPackage.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KBReactPackage.kt index 4e3dd8d8c697..d47069274059 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KBReactPackage.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KBReactPackage.kt @@ -7,11 +7,10 @@ import com.facebook.react.uimanager.ViewManager open class KBReactPackage : ReactPackage { override fun createNativeModules(reactApplicationContext: ReactApplicationContext): List { - // modules.add(); - return ArrayList() + return emptyList() } override fun createViewManagers(reactApplicationContext: ReactApplicationContext): List> { - return mutableListOf() + return emptyList() } } diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KeyStore.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KeyStore.kt index 66c815069010..f13e4b8ce83e 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KeyStore.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeyStore.kt @@ -3,7 +3,6 @@ package io.keybase.ossifrage import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences -import android.os.Build import android.security.keystore.KeyPermanentlyInvalidatedException import android.util.Base64 import io.keybase.ossifrage.keystore.KeyStoreHelper @@ -60,14 +59,14 @@ class KeyStore(private val context: Context, private val prefs: SharedPreference NativeLogger.info("KeyStore: getting users with stored secrets for $serviceName") return try { val keyIterator: Iterator = prefs.all.keys.iterator() - val userNames = ArrayList() + val userNames = mutableListOf() while (keyIterator.hasNext()) { val key = keyIterator.next() if (key.indexOf(sharedPrefKeyPrefix(serviceName)) == 0) { userNames.add(key.substring(sharedPrefKeyPrefix(serviceName).length)) } } - NativeLogger.info("KeyStore: got " + userNames.size + " users with stored secrets for " + serviceName) + NativeLogger.info("KeyStore: got ${userNames.size} users with stored secrets for $serviceName") val packer = MessagePack.newDefaultBufferPacker() packer.packArrayHeader(userNames.size) for (s in userNames) { @@ -91,16 +90,16 @@ class KeyStore(private val context: Context, private val prefs: SharedPreference val entry = ks.getEntry(keyStoreAlias(serviceName), null) ?: throw KeyStoreException("No RSA keys in the keystore") if (entry !is KeyStore.PrivateKeyEntry) { - throw KeyStoreException("Entry is not a PrivateKeyEntry. It is: " + entry.javaClass) + throw KeyStoreException("Entry is not a PrivateKeyEntry. It is: ${entry.javaClass}") } try { val secret = unwrapSecret(entry, wrappedSecret).encoded - NativeLogger.info("KeyStore: retrieved " + secret.size + "-byte secret for " + id) + NativeLogger.info("KeyStore: retrieved ${secret.size}-byte secret for $id") secret } catch (e: InvalidKeyException) { // Invalid key, this can happen when a user changes their lock screen from something to nothing // or enrolls a new finger. See https://developer.android.com/reference/android/security/keystore/KeyPermanentlyInvalidatedException.html - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && e is KeyPermanentlyInvalidatedException) { + if (e is KeyPermanentlyInvalidatedException) { NativeLogger.info("KeyStore: key no longer valid; deleting entry", e) ks.deleteEntry(keyStoreAlias(serviceName)) } @@ -150,7 +149,7 @@ class KeyStore(private val context: Context, private val prefs: SharedPreference @Throws(Exception::class) override fun storeSecret(serviceName: String, key: String, bytes: ByteArray) { val id = "$serviceName:$key" - NativeLogger.info("KeyStore: storing " + bytes.size + "-byte secret for " + id) + NativeLogger.info("KeyStore: storing ${bytes.size}-byte secret for $id") try { val entry = ks.getEntry(keyStoreAlias(serviceName), null) ?: throw KeyStoreException("No RSA keys in the keystore") @@ -160,7 +159,7 @@ class KeyStore(private val context: Context, private val prefs: SharedPreference NativeLogger.error("KeyStore: error storing secret for $id", e) throw e } - NativeLogger.info("KeyStore: stored " + bytes.size + "-byte secret for " + id) + NativeLogger.info("KeyStore: stored ${bytes.size}-byte secret for $id") } companion object { diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt index ec0a2d5e9398..da3d2698d3b4 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt @@ -7,7 +7,6 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper -import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person @@ -16,11 +15,9 @@ import com.google.firebase.messaging.RemoteMessage import io.keybase.ossifrage.MainActivity.Companion.setupKBRuntime import io.keybase.ossifrage.modules.NativeLogger import keybase.Keybase -import keybase.ChatNotification import com.reactnativekb.KbModule import org.json.JSONArray import org.json.JSONObject -import android.util.Log class KeybasePushNotificationListenerService : FirebaseMessagingService() { // This keeps a small ring buffer cache of the last 5 messages per conversation the user @@ -244,14 +241,10 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { } companion object { - @JvmField - var CHAT_CHANNEL_ID = "kb_chat_channel" - @JvmField - var FOLLOW_CHANNEL_ID = "kb_follow_channel" - @JvmField - var DEVICE_CHANNEL_ID = "kb_device_channel" - @JvmField - var GENERAL_CHANNEL_ID = "kb_rest_channel" + const val CHAT_CHANNEL_ID = "kb_chat_channel" + const val FOLLOW_CHANNEL_ID = "kb_follow_channel" + const val DEVICE_CHANNEL_ID = "kb_device_channel" + const val GENERAL_CHANNEL_ID = "kb_rest_channel" fun createNotificationChannel(context: Context) { // Create the NotificationChannel, but only on API 26+ because // the NotificationChannel class is new and not in the support library @@ -323,7 +316,7 @@ class SmallMsgRingBuffer { } } -internal class NotificationData @RequiresApi(api = Build.VERSION_CODES.HONEYCOMB_MR1) constructor(type: String, bundle: Bundle) { +internal class NotificationData(type: String, bundle: Bundle) { val displayPlaintext: Boolean val membersType: Int var convID: String? = null diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt index 00b112ea4e2d..f292022869e1 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt @@ -79,11 +79,10 @@ class MainActivity : ReactActivity() { super.onCreate(null) Handler(Looper.getMainLooper()).postDelayed({ try { - var gc = GuiConfig.getInstance(filesDir) - if (gc != null) { - setBackgroundColor(gc.getDarkMode()) - } + val gc = GuiConfig.getInstance(filesDir) + gc?.let { setBackgroundColor(it.getDarkMode()) } } catch (e: Exception) { + NativeLogger.warn("Error reading GuiConfig in onCreate", e) } }, 300) KeybasePushNotificationListenerService.createNotificationChannel(this) @@ -258,6 +257,7 @@ class MainActivity : ReactActivity() { // Avoid getParcelableArrayListExtra() here: some senders incorrectly use ACTION_SEND_MULTIPLE // but provide a single Uri in EXTRA_STREAM, which would cause a ClassCast log/warning. + @Suppress("DEPRECATION") when (val streamExtra = intent.extras?.get(Intent.EXTRA_STREAM)) { is Uri -> uris.add(streamExtra) is ArrayList<*> -> streamExtra.filterIsInstance().forEach { uris.add(it) } @@ -425,11 +425,10 @@ class MainActivity : ReactActivity() { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) try { - var gc = GuiConfig.getInstance(filesDir) - if (gc != null) { - setBackgroundColor(gc.getDarkMode()) - } + val gc = GuiConfig.getInstance(filesDir) + gc?.let { setBackgroundColor(it.getDarkMode()) } } catch (e: Exception) { + NativeLogger.warn("Error reading GuiConfig in onConfigurationChanged", e) } if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO) { isUsingHardwareKeyboard = true @@ -439,13 +438,12 @@ class MainActivity : ReactActivity() { } fun setBackgroundColor(pref: DarkModePreference) { - val bgColor: Int - bgColor = if (pref == DarkModePreference.System) { - if (colorSchemeForCurrentConfiguration() == "light") R.color.white else R.color.black - } else if (pref == DarkModePreference.AlwaysDark) { - R.color.black - } else { - R.color.white + val bgColor = when (pref) { + DarkModePreference.System -> { + if (colorSchemeForCurrentConfiguration() == "light") R.color.white else R.color.black + } + DarkModePreference.AlwaysDark -> R.color.black + DarkModePreference.AlwaysLight -> R.color.white } val mainWindow = this.window val handler = Handler(Looper.getMainLooper()) diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt index 5fc27ec8065b..1ebf6bac74d0 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt @@ -1,5 +1,3 @@ -@file:Suppress("DEPRECATION") - package io.keybase.ossifrage import android.app.Application @@ -8,7 +6,6 @@ import android.content.res.Configuration import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import androidx.multidex.MultiDex import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import androidx.work.WorkRequest @@ -30,7 +27,6 @@ import io.keybase.ossifrage.modules.BackgroundSyncWorker import io.keybase.ossifrage.modules.NativeLogger import keybase.Keybase import java.util.concurrent.TimeUnit -import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ReactNativeHostWrapper internal class AppLifecycleListener(private val context: Context?) : @@ -40,6 +36,7 @@ internal class AppLifecycleListener(private val context: Context?) : try { Glide.get(context!!).clearDiskCache() } catch (e: Exception) { + NativeLogger.warn("AppLifecycleListener: error clearing Glide disk cache", e) } }.start() } @@ -95,11 +92,6 @@ class MainApplication : Application(), ReactApplication { ) } - override fun attachBaseContext(base: Context) { - super.attachBaseContext(base) - MultiDex.install(this) - } - override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) onConfigurationChanged(this, newConfig) diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/keystore/KeyStoreHelper.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/keystore/KeyStoreHelper.kt index 5cd423eeacde..5be3d797d87b 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/keystore/KeyStoreHelper.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/keystore/KeyStoreHelper.kt @@ -1,8 +1,6 @@ package io.keybase.ossifrage.keystore -import android.annotation.TargetApi import android.content.Context -import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.math.BigInteger @@ -11,41 +9,22 @@ import java.security.KeyPairGenerator import java.security.KeyStoreException import java.security.NoSuchAlgorithmException import java.security.NoSuchProviderException -import java.security.spec.AlgorithmParameterSpec import java.util.Calendar import javax.security.auth.x500.X500Principal object KeyStoreHelper { - @TargetApi(Build.VERSION_CODES.KITKAT) @Throws(KeyStoreException::class, NoSuchProviderException::class, NoSuchAlgorithmException::class, InvalidAlgorithmParameterException::class) fun generateRSAKeyPair(ctx: Context?, keyAlias: String) { val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore") - val spec: AlgorithmParameterSpec - val endTime = Calendar.getInstance() - endTime.add(Calendar.YEAR, 10) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - spec = KeyGenParameterSpec.Builder( - keyAlias, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) - .setBlockModes(KeyProperties.BLOCK_MODE_ECB) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) - .setCertificateSerialNumber(BigInteger.ONE) - .setCertificateSubject(X500Principal("CN=$keyAlias")) - .setKeySize(2048) - .build() - } else { - @Suppress("DEPRECATION") - spec = android.security.KeyPairGeneratorSpec.Builder(ctx!!) - .setAlias(keyAlias) - .setEncryptionRequired() - .setSerialNumber(BigInteger.ONE) - .setSubject(X500Principal("CN=$keyAlias")) - .setStartDate(Calendar.getInstance().time) - .setEndDate(endTime.time) - .setKeySize(2048) - .build() - } + val spec = KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_ECB) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) + .setCertificateSerialNumber(BigInteger.ONE) + .setCertificateSubject(X500Principal("CN=$keyAlias")) + .setKeySize(2048) + .build() kpg.initialize(spec) kpg.generateKeyPair() } diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/modules/NativeLogger.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/modules/NativeLogger.kt index c185214df84c..cfd9c3eb532d 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/modules/NativeLogger.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/modules/NativeLogger.kt @@ -18,7 +18,7 @@ class NativeLogger(reactContext: ReactApplicationContext?) : ReactContextBaseJav private fun formatLine(tagPrefix: String, toLog: String): String { // Copies the Style JS outputs in native/logger.native.tsx - return tagPrefix + NAME + ": [" + System.currentTimeMillis() + ",\"" + toLog + "\"]" + return "${tagPrefix}${NAME}: [${System.currentTimeMillis()},\"$toLog\"]" } fun error(log: String) { diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/modules/StorybookConstants.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/modules/StorybookConstants.kt index e24f193135ab..b888175bdafd 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/modules/StorybookConstants.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/modules/StorybookConstants.kt @@ -6,8 +6,8 @@ import io.keybase.ossifrage.BuildConfig class StorybookConstants(reactContext: ReactApplicationContext?) : ReactContextBaseJavaModule(reactContext) { override fun getConstants(): Map? { - val isStoryBook = BuildConfig.BUILD_TYPE === "storyBook" - val constants: MutableMap = HashMap() + val isStoryBook = BuildConfig.BUILD_TYPE == "storyBook" + val constants: MutableMap = mutableMapOf() constants["isStorybook"] = isStoryBook return constants } diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/util/DeviceLockType.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/util/DeviceLockType.kt index 649ce9328e2a..11a60a3bf0c9 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/util/DeviceLockType.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/util/DeviceLockType.kt @@ -73,25 +73,26 @@ object DeviceLockType { fun getCurrent(contentResolver: ContentResolver?): Int { val mode = Settings.Secure.getLong(contentResolver, PASSWORD_TYPE_KEY, DevicePolicyManager.PASSWORD_QUALITY_SOMETHING.toLong()) - return if (mode == DevicePolicyManager.PASSWORD_QUALITY_SOMETHING.toLong()) { - @Suppress("DEPRECATION") - if (Settings.Secure.getInt(contentResolver, Settings.Secure.LOCK_PATTERN_ENABLED, 0) == 1) { - PATTERN - } else NONE_OR_SLIDER - } else if (mode == DevicePolicyManager.PASSWORD_QUALITY_BIOMETRIC_WEAK.toLong()) { - val dataDirPath = Environment.getDataDirectory().absolutePath - if (nonEmptyFileExists("$dataDirPath/system/gesture.key")) { - FACE_WITH_PATTERN - } else if (nonEmptyFileExists("$dataDirPath/system/password.key")) { - FACE_WITH_PIN - } else FACE_WITH_SOMETHING_ELSE - } else if (mode == DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC.toLong()) { - PASSWORD_ALPHANUMERIC - } else if (mode == DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC.toLong()) { - PASSWORD_ALPHABETIC - } else if (mode == DevicePolicyManager.PASSWORD_QUALITY_NUMERIC.toLong()) { - PIN - } else SOMETHING_ELSE + return when (mode) { + DevicePolicyManager.PASSWORD_QUALITY_SOMETHING.toLong() -> { + @Suppress("DEPRECATION") + if (Settings.Secure.getInt(contentResolver, Settings.Secure.LOCK_PATTERN_ENABLED, 0) == 1) { + PATTERN + } else NONE_OR_SLIDER + } + DevicePolicyManager.PASSWORD_QUALITY_BIOMETRIC_WEAK.toLong() -> { + val dataDirPath = Environment.getDataDirectory().absolutePath + if (nonEmptyFileExists("$dataDirPath/system/gesture.key")) { + FACE_WITH_PATTERN + } else if (nonEmptyFileExists("$dataDirPath/system/password.key")) { + FACE_WITH_PIN + } else FACE_WITH_SOMETHING_ELSE + } + DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC.toLong() -> PASSWORD_ALPHANUMERIC + DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC.toLong() -> PASSWORD_ALPHABETIC + DevicePolicyManager.PASSWORD_QUALITY_NUMERIC.toLong() -> PIN + else -> SOMETHING_ELSE + } } private fun nonEmptyFileExists(filename: String): Boolean { diff --git a/shared/desktop/yarn-helper/index.tsx b/shared/desktop/yarn-helper/index.tsx index 181b987ef3ef..0a18d84f846d 100644 --- a/shared/desktop/yarn-helper/index.tsx +++ b/shared/desktop/yarn-helper/index.tsx @@ -125,8 +125,8 @@ const decorateInfo = (info: Command) => { const getMsgPack = () => { if (process.platform === 'darwin') { - const ver = '6.1.0' - const shasum = '09b6b71cdfb4b176e5bb12b02b4ffc290ec10b41' + const ver = '7.0.0' + const shasum = '37bbdbf69ef44392c7af215b9cb419891a9e1c9c' const file = `msgpack-cxx-${ver}.tar.gz` const url = `https://github.com/msgpack/msgpack-c/releases/download/cpp-${ver}/${file}` const prefix = path.resolve(__dirname, '..', '..', 'node_modules') diff --git a/shared/engine/index.platform.native.tsx b/shared/engine/index.platform.native.tsx index 929d4979eb4e..4cd92255bcac 100644 --- a/shared/engine/index.platform.native.tsx +++ b/shared/engine/index.platform.native.tsx @@ -1,5 +1,4 @@ import {TransportShared, sharedCreateClient, rpcLog} from './transport-shared' -import {encode} from '@msgpack/msgpack' import type {IncomingRPCCallbackType, ConnectDisconnectCB} from './index.platform' import logger from '@/logger' import {engineReset, getNativeEmitter, notifyJSReady} from 'react-native-kb' @@ -33,17 +32,11 @@ class NativeTransport extends TransportShared { // A custom send override to write to the react native bridge send(msg: unknown) { - const packed = encode(msg) - const len = encode(packed.length) - const buf = new Uint8Array(len.length + packed.length) - buf.set(len, 0) - buf.set(packed, len.length) - // Pass data over to the native side to be handled, with JSI! try { if (!global.rpcOnGo) { logger.error('>>>> rpcOnGo send before rpcOnGo global?') } - global.rpcOnGo?.(buf.buffer) + global.rpcOnGo?.(msg) } catch (e) { logger.error('>>>> rpcOnGo JS thrown!', e) } @@ -60,9 +53,16 @@ function createClient( new NativeTransport(incomingRPCCallback, connectCallback, disconnectCallback) ) - global.rpcOnJs = (objs: unknown) => { + global.rpcOnJs = (objs: unknown, count: number) => { try { - client.transport._dispatch(objs) + if (count > 1) { + const arr = objs as Array + for (const obj of arr) { + client.transport._dispatch(obj) + } + } else { + client.transport._dispatch(objs) + } } catch (e) { logger.error('>>>> rpcOnJs JS thrown!', e) } diff --git a/shared/globals.d.ts b/shared/globals.d.ts index 4aa82e8af833..f8ca8884020a 100644 --- a/shared/globals.d.ts +++ b/shared/globals.d.ts @@ -34,8 +34,8 @@ declare global { var __VERSION__: string var __FILE_SUFFIX__: string var __PROFILE__: boolean - var rpcOnGo: undefined | ((b: unknown) => void) - var rpcOnJs: undefined | ((b: unknown) => void) + var rpcOnGo: undefined | ((msg: unknown) => void) + var rpcOnJs: undefined | ((objs: unknown, count: number) => void) // RN var __turboModuleProxy: unknown } diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index ac5158070149..e102c7307b19 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -1,3 +1,4 @@ +import BackgroundTasks import Expo import React import ReactAppDependencyProvider @@ -7,6 +8,9 @@ import UserNotifications import AVFoundation import ExpoModulesCore import Keybasego +import os + +private let log = Logger(subsystem: "com.keybase.app", category: "delegate") class KeyboardWindow: UIWindow { override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { @@ -32,7 +36,7 @@ class KeyboardWindow: UIWindow { } } -@UIApplicationMain +@main public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInteractionDelegate { var window: UIWindow? @@ -67,7 +71,7 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID } NotificationCenter.default.addObserver(forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main) { [weak self] notification in - NSLog("Memory warning received - deferring GC during React Native initialization") + log.info("Memory warning received - deferring GC during React Native initialization") // see if this helps avoid this crash DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { guard let self = self, self.reactNativeFactory != nil else { return } @@ -86,7 +90,8 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID bindReactNativeFactory(factory) #if os(iOS) || os(tvOS) - window = KeyboardWindow(frame: UIScreen.main.bounds) + let screenBounds = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.screen.bounds ?? UIScreen.main.bounds + window = KeyboardWindow(frame: screenBounds) factory.startReactNative( withModuleName: "Keybase", in: window, @@ -138,18 +143,13 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID } if self.startupLogFileHandle == nil { - do { - if !FileManager.default.fileExists(atPath: logFilePath) { - FileManager.default.createFile(atPath: logFilePath, contents: nil, attributes: nil) - } + if !FileManager.default.fileExists(atPath: logFilePath) { + FileManager.default.createFile(atPath: logFilePath, contents: nil, attributes: nil) + } - if let fileHandle = FileHandle(forWritingAtPath: logFilePath) { - fileHandle.seekToEndOfFile() - self.startupLogFileHandle = fileHandle - } - } catch { - NSLog("Error opening startup timing log file: \(error)") - return + if let fileHandle = try? FileHandle(forWritingTo: URL(fileURLWithPath: logFilePath)) { + try? fileHandle.seekToEnd() + self.startupLogFileHandle = fileHandle } } @@ -166,26 +166,22 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) let dateString = dateFormatter.string(from: now) let timestamp = String(format: "%@.%06dZ", dateString, microseconds) - - let fileName = (file as NSString).lastPathComponent - let logMessage = String(format: "%@ ▶ [DEBU keybase %@:%d] Delegate startup: %@\n", timestamp, fileName, line, message) + + let fileName = URL(fileURLWithPath: file).lastPathComponent + let logMessage = String(format: "%@ \u{25B6} [DEBU keybase %@:%d] Delegate startup: %@\n", timestamp, fileName, line, message) guard let logData = logMessage.data(using: .utf8) else { return } - do { - fileHandle.write(logData) - fileHandle.synchronizeFile() - } catch { - NSLog("Error writing startup timing log: \(error)") - } + try? fileHandle.write(contentsOf: logData) + try? fileHandle.synchronize() } private func closeStartupLogFile() { if let fileHandle = self.startupLogFileHandle { - fileHandle.synchronizeFile() - fileHandle.closeFile() + try? fileHandle.synchronize() + try? fileHandle.close() self.startupLogFileHandle = nil } } @@ -208,18 +204,18 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID self.writeStartupTimingLog("Before Go init") // Initialize Go synchronously - happens during splash screen - NSLog("Starting KeybaseInit (synchronous)...") + log.info("Starting KeybaseInit (synchronous)...") var err: NSError? let shareIntentDonator = ShareIntentDonatorImpl() Keybasego.KeybaseInit(self.fsPaths["homedir"], self.fsPaths["sharedHome"], self.fsPaths["logFile"], "prod", securityAccessGroupOverride, nil, nil, systemVer, isIPad, nil, isIOS, shareIntentDonator, &err) - if let err { NSLog("KeybaseInit FAILED: \(err)") } - + if let err { log.error("KeybaseInit FAILED: \(err.localizedDescription, privacy: .public)") } + self.writeStartupTimingLog("After Go init") } func notifyAppState(_ application: UIApplication) { let state = application.applicationState - NSLog("notifyAppState: notifying service with new appState: \(state.rawValue)") + log.info("notifyAppState: notifying service with new appState: \(state.rawValue)") switch state { case .active: Keybasego.KeybaseSetAppStateForeground() case .background: Keybasego.KeybaseSetAppStateBackground() @@ -240,11 +236,12 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID rootView.backgroundColor = .systemBackground // Snapshot resizing workaround for iPad - var dim = UIScreen.main.bounds.width - if UIScreen.main.bounds.height > dim { - dim = UIScreen.main.bounds.height + let screenBounds = self.window?.windowScene?.screen.bounds ?? UIScreen.main.bounds + var dim = screenBounds.width + if screenBounds.height > dim { + dim = screenBounds.height } - let square = CGRect(origin: UIScreen.main.bounds.origin, size: CGSize(width: dim, height: dim)) + let square = CGRect(origin: screenBounds.origin, size: CGSize(width: dim, height: dim)) self.resignImageView = UIImageView(frame: square) self.resignImageView?.contentMode = .center self.resignImageView?.alpha = 0 @@ -252,7 +249,10 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID self.resignImageView?.image = UIImage(named: "LaunchImage") self.window?.addSubview(self.resignImageView!) - UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum) + BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.keybase.app.refresh", using: nil) { task in + self.handleAppRefresh(task: task as! BGAppRefreshTask) + } + scheduleAppRefresh() } func addDrop(_ rootView: UIView) { @@ -282,12 +282,28 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID self.iph?.startProcessing() } - public override func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - NSLog("Background fetch started...") + func scheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: "com.keybase.app.refresh") + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + log.error("Could not schedule app refresh: \(error.localizedDescription, privacy: .public)") + } + } + + func handleAppRefresh(task: BGAppRefreshTask) { + scheduleAppRefresh() + + task.expirationHandler = { + log.warning("Background refresh task expired") + } + DispatchQueue.global(qos: .default).async { + log.info("Background fetch started...") Keybasego.KeybaseBackgroundSync() - completionHandler(.newData) - NSLog("Background fetch completed...") + task.setTaskCompleted(success: true) + log.info("Background fetch completed...") } } @@ -315,9 +331,9 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID var err: NSError? Keybasego.KeybaseHandleBackgroundNotification(convID, body, "", sender, membersType, displayPlaintext, messageID, pushID, badgeCount, unixTime, soundName, pusher, false, &err) - if let err { NSLog("Failed to handle in engine: \(err)") } + if let err { log.error("Failed to handle in engine: \(err.localizedDescription, privacy: .public)") } completionHandler(.newData) - NSLog("Remote notification handle finished...") + log.info("Remote notification handle finished...") } } else { var notificationDict = Dictionary(uniqueKeysWithValues: notification.map { (String(describing: $0.key), $0.value) }) @@ -332,9 +348,6 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID var notificationDict = Dictionary(uniqueKeysWithValues: userInfo.map { (String(describing: $0.key), $0.value) }) notificationDict["userInteraction"] = true - let type = notificationDict["type"] as? String ?? "unknown" - let convID = notificationDict["convID"] as? String ?? notificationDict["c"] as? String ?? "unknown" - // Store the notification so it can be processed when app becomes active // This ensures navigation works even if React Native isn't ready yet KbSetInitialNotification(notificationDict) @@ -358,40 +371,39 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID } func hideCover() { - NSLog("hideCover: cancelling outstanding animations...") + log.info("hideCover: cancelling outstanding animations...") self.resignImageView?.layer.removeAllAnimations() self.resignImageView?.alpha = 0 } public override func applicationWillResignActive(_ application: UIApplication) { - NSLog("applicationWillResignActive: cancelling outstanding animations...") + log.info("applicationWillResignActive: cancelling outstanding animations...") self.resignImageView?.layer.removeAllAnimations() self.resignImageView?.superview?.bringSubviewToFront(self.resignImageView!) - NSLog("applicationWillResignActive: rendering keyz screen...") + log.info("applicationWillResignActive: rendering keyz screen...") UIView.animate(withDuration: 0.3, delay: 0.1, options: .beginFromCurrentState) { self.resignImageView?.alpha = 1 } completion: { finished in - NSLog("applicationWillResignActive: rendered keyz screen. Finished: \(finished)") + log.info("applicationWillResignActive: rendered keyz screen. Finished: \(finished)") } Keybasego.KeybaseSetAppStateInactive() } public override func applicationDidEnterBackground(_ application: UIApplication) { PerfFPSMonitor.appDidEnterBackground() - application.ignoreSnapshotOnNextApplicationLaunch() - NSLog("applicationDidEnterBackground: cancelling outstanding animations...") + log.info("applicationDidEnterBackground: cancelling outstanding animations...") self.resignImageView?.layer.removeAllAnimations() - NSLog("applicationDidEnterBackground: setting keyz screen alpha to 1.") + log.info("applicationDidEnterBackground: setting keyz screen alpha to 1.") self.resignImageView?.alpha = 1 - NSLog("applicationDidEnterBackground: notifying go.") + log.info("applicationDidEnterBackground: notifying go.") let requestTime = Keybasego.KeybaseAppDidEnterBackground() - NSLog("applicationDidEnterBackground: after notifying go.") + log.info("applicationDidEnterBackground: after notifying go.") if requestTime && (self.shutdownTask == UIBackgroundTaskIdentifier.invalid) { let app = UIApplication.shared self.shutdownTask = app.beginBackgroundTask { - NSLog("applicationDidEnterBackground: shutdown task run.") + log.info("applicationDidEnterBackground: shutdown task run.") Keybasego.KeybaseAppWillExit(PushNotifier()) let task = self.shutdownTask if task != .invalid { @@ -412,17 +424,15 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID } public override func applicationDidBecomeActive(_ application: UIApplication) { - NSLog("applicationDidBecomeActive: hiding keyz screen.") + log.info("applicationDidBecomeActive: hiding keyz screen.") hideCover() - NSLog("applicationDidBecomeActive: notifying service.") + log.info("applicationDidBecomeActive: notifying service.") notifyAppState(application) // Check if there's a stored notification with userInteraction that needs to be processed // This handles the case where app was backgrounded and notification was clicked // but React Native wasn't ready yet if let storedNotification = KbGetAndClearInitialNotification() { - let type = storedNotification["type"] as? String ?? "unknown" - let convID = storedNotification["convID"] as? String ?? storedNotification["c"] as? String ?? "unknown" let userInteraction = storedNotification["userInteraction"] as? Bool ?? false if userInteraction { @@ -430,22 +440,22 @@ public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UID if alreadyReEmitted { KbSetInitialNotification(storedNotification) } else { - NSLog("applicationDidBecomeActive: stored notification has userInteraction=true, emitting") + log.info("applicationDidBecomeActive: stored notification has userInteraction=true, emitting") KbEmitPushNotification(storedNotification) - var copy = Dictionary(uniqueKeysWithValues: storedNotification.map { (String(describing: $0.key), $0.value) }) + var copy = storedNotification copy["reEmittedInBecomeActive"] = true - KbSetInitialNotification(copy as NSDictionary as! [AnyHashable : Any]) + KbSetInitialNotification(copy) } } else { - NSLog("applicationDidBecomeActive: stored notification has userInteraction=false, skipping") + log.info("applicationDidBecomeActive: stored notification has userInteraction=false, skipping") } } else { - NSLog("applicationDidBecomeActive: no stored notification found") + log.info("applicationDidBecomeActive: no stored notification found") } } public override func applicationWillEnterForeground(_ application: UIApplication) { - NSLog("applicationWillEnterForeground: hiding keyz screen.") + log.info("applicationWillEnterForeground: hiding keyz screen.") hideCover() } diff --git a/shared/ios/Keybase/Fs.swift b/shared/ios/Keybase/Fs.swift index 10dc2b486a99..1b1975df5cc7 100644 --- a/shared/ios/Keybase/Fs.swift +++ b/shared/ios/Keybase/Fs.swift @@ -1,30 +1,35 @@ import Foundation +import os + +private let log = Logger(subsystem: "com.keybase.app", category: "fs") @objc class FsHelper: NSObject { + private static let cacheKeybaseURL = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Caches/Keybase") + private static let appSupportKeybaseURL = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support/Keybase") + @objc func setupFs(_ skipLogFile: Bool, setupSharedHome shouldSetupSharedHome: Bool) -> [String: String] { let setupFsStartTime = CFAbsoluteTimeGetCurrent() - NSLog("setupFs: starting") + log.info("setupFs: starting") var home = NSHomeDirectory() let sharedURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.keybase") - var sharedHome = sharedURL?.relativePath ?? "" + var sharedHome = sharedURL?.path ?? "" home = setupAppHome(home: home, sharedHome: sharedHome) if shouldSetupSharedHome { sharedHome = setupSharedHome(home: home, sharedHome: sharedHome) } - let appKeybasePath = Self.getAppKeybasePath() + let appKeybaseURL = URL(fileURLWithPath: Self.getAppKeybasePath()) // Put logs in a subdir that is entirely background readable - let oldLogPath = ("~/Library/Caches/Keybase" as NSString).expandingTildeInPath - let logPath = (oldLogPath as NSString).appendingPathComponent("logs") - let serviceLogFile = skipLogFile ? "" : (logPath as NSString).appendingPathComponent("ios.log") + let logURL = Self.cacheKeybaseURL.appendingPathComponent("logs") + let serviceLogFile = skipLogFile ? "" : logURL.appendingPathComponent("ios.log").path if !skipLogFile { // cleanup old log files let fm = FileManager.default ["ios.log", "ios.log.ek"].forEach { - try? fm.removeItem(atPath: (oldLogPath as NSString).appendingPathComponent($0)) + try? fm.removeItem(at: Self.cacheKeybaseURL.appendingPathComponent($0)) } } // Create LevelDB and log directories with a slightly lower data protection @@ -44,13 +49,13 @@ import Foundation "synced_tlf_config", "logs" ].forEach { - createBackgroundReadableDirectory(path: (appKeybasePath as NSString).appendingPathComponent($0), setAllFiles: true) + createBackgroundReadableDirectory(path: appKeybaseURL.appendingPathComponent($0).path, setAllFiles: true) } // Mark avatars, which are in the caches dir - createBackgroundReadableDirectory(path: (oldLogPath as NSString).appendingPathComponent("avatars"), setAllFiles: true) + createBackgroundReadableDirectory(path: Self.cacheKeybaseURL.appendingPathComponent("avatars").path, setAllFiles: true) let setupFsElapsed = CFAbsoluteTimeGetCurrent() - setupFsStartTime - NSLog("setupFs: completed in %.3f seconds", setupFsElapsed) + log.info("setupFs: completed in \(setupFsElapsed, format: .fixed(precision: 3)) seconds") return [ "home": home, @@ -60,12 +65,14 @@ import Foundation } private func addSkipBackupAttribute(to path: String) -> Bool { - let url = Foundation.URL(fileURLWithPath: path) + var url = URL(fileURLWithPath: path) do { - try (url as NSURL).setResourceValue(true, forKey: URLResourceKey.isExcludedFromBackupKey) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try url.setResourceValues(resourceValues) return true } catch { - NSLog("Error excluding \(url.lastPathComponent) from backup \(error)") + log.error("Error excluding \(url.lastPathComponent, privacy: .public) from backup \(error.localizedDescription, privacy: .public)") return false } } @@ -78,82 +85,82 @@ import Foundation // files are still stored on the disk encrypted (note for the chat database, // it means we are encrypting it twice), and are inaccessible otherwise. let noProt = [FileAttributeKey.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] - NSLog("creating background readable directory: path: \(path) setAllFiles: \(setAllFiles)") + log.info("creating background readable directory: path: \(path, privacy: .public) setAllFiles: \(setAllFiles)") _ = try? fm.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: noProt) do { try fm.setAttributes(noProt, ofItemAtPath: path) } catch { - NSLog("Error setting file attributes on path: \(path) error: \(error)") + log.error("Error setting file attributes on path: \(path, privacy: .public) error: \(error.localizedDescription, privacy: .public)") } guard setAllFiles else { - NSLog("setAllFiles is false, so returning now") + log.info("setAllFiles is false, so returning now") return } - NSLog("setAllFiles is true charging forward") + log.info("setAllFiles is true charging forward") // Recursively set attributes on all subdirectories and files + let pathURL = URL(fileURLWithPath: path) var fileCount = 0 if let enumerator = fm.enumerator(atPath: path) { for case let file as String in enumerator { - let filePath = (path as NSString).appendingPathComponent(file) + let filePath = pathURL.appendingPathComponent(file).path do { try fm.setAttributes(noProt, ofItemAtPath: filePath) fileCount += 1 } catch { - NSLog("Error setting file attributes on: \(filePath) error: \(error)") + log.error("Error setting file attributes on: \(filePath, privacy: .public) error: \(error.localizedDescription, privacy: .public)") } } let dirElapsed = CFAbsoluteTimeGetCurrent() - dirStartTime - NSLog("createBackgroundReadableDirectory completed for: \(path), processed \(fileCount) files, total: %.3f seconds", dirElapsed) + log.info("createBackgroundReadableDirectory completed for: \(path, privacy: .public), processed \(fileCount) files, total: \(dirElapsed, format: .fixed(precision: 3)) seconds") } else { - NSLog("Error creating enumerator for path: \(path)") + log.error("Error creating enumerator for path: \(path, privacy: .public)") } } private func maybeMigrateDirectory(source: String, dest: String) -> Bool { let fm = FileManager.default + let sourceURL = URL(fileURLWithPath: source) + let destURL = URL(fileURLWithPath: dest) do { // Always do this move in case it doesn't work on previous attempts. let sourceContents = try fm.contentsOfDirectory(atPath: source) for file in sourceContents { - let path = (source as NSString).appendingPathComponent(file) - let destPath = (dest as NSString).appendingPathComponent(file) + let filePath = sourceURL.appendingPathComponent(file).path + let destPath = destURL.appendingPathComponent(file).path var isDir: ObjCBool = false - if fm.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue { - NSLog("skipping directory: \(file)") + if fm.fileExists(atPath: filePath, isDirectory: &isDir), isDir.boolValue { + log.info("skipping directory: \(file, privacy: .public)") continue } do { - try fm.moveItem(atPath: path, toPath: destPath) + try fm.moveItem(atPath: filePath, toPath: destPath) } catch let error as NSError { if error.code == NSFileWriteFileExistsError { continue } - NSLog("Error moving file: \(file) error: \(error)") + log.error("Error moving file: \(file, privacy: .public) error: \(error.localizedDescription, privacy: .public)") return false } } return true } catch { - NSLog("Error listing app contents directory: \(error)") + log.error("Error listing app contents directory: \(error.localizedDescription, privacy: .public)") return false } } @objc static func getAppKeybasePath() -> String { - return ("~/Library/Application Support/Keybase" as NSString).expandingTildeInPath + return appSupportKeybaseURL.path } @objc static func getEraseableKVPath() -> String { - return (getAppKeybasePath() as NSString).appendingPathComponent("eraseablekvstore/device-eks") + return appSupportKeybaseURL.appendingPathComponent("eraseablekvstore/device-eks").path } private func setupAppHome(home: String, sharedHome: String) -> String { - let tempUrl = FileManager.default.temporaryDirectory - // workaround a problem where iOS dyld3 loader crashes if accessing .closure files - // with complete data protection on - let dyldDir = (tempUrl.path as NSString).appendingPathComponent("com.apple.dyld") + let dyldDir = FileManager.default.temporaryDirectory.appendingPathComponent("com.apple.dyld").path let appKeybasePath = Self.getAppKeybasePath() let appEraseableKVPath = Self.getEraseableKVPath() @@ -168,8 +175,8 @@ import Foundation private func setupSharedHome(home: String, sharedHome: String) -> String { let appKeybasePath = Self.getAppKeybasePath() let appEraseableKVPath = Self.getEraseableKVPath() - let sharedKeybasePath = (sharedHome as NSString).appendingPathComponent("Library/Application Support/Keybase") - let sharedEraseableKVPath = (sharedKeybasePath as NSString).appendingPathComponent("eraseablekvstore/device-eks") + let sharedKeybasePath = URL(fileURLWithPath: sharedHome).appendingPathComponent("Library/Application Support/Keybase").path + let sharedEraseableKVPath = URL(fileURLWithPath: sharedKeybasePath).appendingPathComponent("eraseablekvstore/device-eks").path createBackgroundReadableDirectory(path: sharedKeybasePath, setAllFiles: true) createBackgroundReadableDirectory(path: sharedEraseableKVPath, setAllFiles: true) diff --git a/shared/ios/Keybase/Info.plist b/shared/ios/Keybase/Info.plist index fc0bc7c9f8f2..1285b1876ca9 100644 --- a/shared/ios/Keybase/Info.plist +++ b/shared/ios/Keybase/Info.plist @@ -87,6 +87,10 @@ SourceCodePro-Semibold.ttf kb.ttf + BGTaskSchedulerPermittedIdentifiers + + com.keybase.app.refresh + UIBackgroundModes fetch diff --git a/shared/ios/Keybase/PerfFPSMonitor.swift b/shared/ios/Keybase/PerfFPSMonitor.swift index 278eed6905ee..a0d97bd6378d 100644 --- a/shared/ios/Keybase/PerfFPSMonitor.swift +++ b/shared/ios/Keybase/PerfFPSMonitor.swift @@ -1,6 +1,9 @@ import Foundation import QuartzCore import UIKit +import os + +private let log = Logger(subsystem: "com.keybase.app", category: "perf") /// Lightweight FPS monitor using CADisplayLink. /// Activated by the `-PERF_FPS_MONITOR` launch argument. @@ -35,7 +38,7 @@ import UIKit // Use .common so it fires during scroll tracking displayLink?.add(to: .main, forMode: .common) - NSLog("PerfFPSMonitor: started") + log.info("PerfFPSMonitor: started") } func stop() { @@ -48,7 +51,7 @@ import UIKit samples.append(frameCount) } writeResults() - NSLog("PerfFPSMonitor: stopped, wrote %d samples to %@", samples.count, PerfFPSMonitor.outputPath) + log.info("PerfFPSMonitor: stopped, wrote \(self.samples.count) samples to \(PerfFPSMonitor.outputPath, privacy: .public)") } @objc private func tick(_ link: CADisplayLink) { diff --git a/shared/ios/Keybase/Pusher.swift b/shared/ios/Keybase/Pusher.swift index a32c59669693..5dd98596f484 100644 --- a/shared/ios/Keybase/Pusher.swift +++ b/shared/ios/Keybase/Pusher.swift @@ -1,6 +1,9 @@ import Foundation import UserNotifications import Keybasego +import os + +private let log = Logger(subsystem: "com.keybase.app", category: "push") class PushNotifier: NSObject, Keybasego.KeybasePushNotifierProtocol { func localNotification(_ ident: String?, msg: String?, badgeCount: Int, soundName: String?, convID: String?, typ: String?) { @@ -14,15 +17,15 @@ class PushNotifier: NSObject, Keybasego.KeybasePushNotifierProtocol { let request = UNNotificationRequest(identifier: ident ?? UUID().uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) { error in if let error = error { - NSLog("local notification failed: %@", error.localizedDescription) + log.error("local notification failed: \(error.localizedDescription, privacy: .public)") } } } - + func display(_ n: KeybaseChatNotification?) { guard let notification = n else { return } guard let message = notification.message else { return } - + let ident = "\(notification.convID):\(message.id_)" let msg: String if notification.isPlaintext && !message.plaintext.isEmpty { diff --git a/shared/ios/Keybase/ShareIntentDonatorImpl.swift b/shared/ios/Keybase/ShareIntentDonatorImpl.swift index c5bde51cd30a..98af4182ba52 100644 --- a/shared/ios/Keybase/ShareIntentDonatorImpl.swift +++ b/shared/ios/Keybase/ShareIntentDonatorImpl.swift @@ -9,6 +9,9 @@ import Foundation import Intents import Keybasego import UIKit +import os + +private let log = Logger(subsystem: "com.keybase.app", category: "share") private struct ShareConversation: Decodable { let convID: String @@ -29,29 +32,29 @@ private struct ShareConversation: Decodable { class ShareIntentDonatorImpl: NSObject, Keybasego.KeybaseShareIntentDonatorProtocol { func deleteAllDonations() { INInteraction.deleteAll { _ in } - NSLog("ShareIntentDonator: deleteAllDonations completed") + log.info("ShareIntentDonator: deleteAllDonations completed") } func deleteDonation(_ conversationID: String?) { guard let id = conversationID, !id.isEmpty else { return } INInteraction.delete(with: id, completion: nil) - NSLog("ShareIntentDonator: deleteDonation completed for %@", id) + log.info("ShareIntentDonator: deleteDonation completed for \(id, privacy: .public)") } func donateShareConversations(_ conversationsJSON: String?) { guard let json = conversationsJSON, let data = json.data(using: .utf8) else { - NSLog("ShareIntentDonator: donateShareConversations: nil or invalid JSON") + log.info("ShareIntentDonator: donateShareConversations: nil or invalid JSON") return } guard let conversations = try? JSONDecoder().decode([ShareConversation].self, from: data) else { - NSLog("ShareIntentDonator: donateShareConversations: JSON decode failed") + log.info("ShareIntentDonator: donateShareConversations: JSON decode failed") return } guard !conversations.isEmpty else { - NSLog("ShareIntentDonator: donateShareConversations: empty conversations array") + log.info("ShareIntentDonator: donateShareConversations: empty conversations array") return } - NSLog("ShareIntentDonator: donateShareConversations: donating %d conversations", conversations.count) + log.info("ShareIntentDonator: donateShareConversations: donating \(conversations.count) conversations") donateConversations(conversations) } @@ -163,7 +166,7 @@ class ShareIntentDonatorImpl: NSObject, Keybasego.KeybaseShareIntentDonatorProto let interaction = INInteraction(intent: intent, response: nil) interaction.donate { error in if let error = error { - NSLog("ShareIntentDonator: donateIntent failed for %@: %@", intent.conversationIdentifier ?? "?", error.localizedDescription) + log.error("ShareIntentDonator: donateIntent failed for \(intent.conversationIdentifier ?? "?", privacy: .public): \(error.localizedDescription, privacy: .public)") } } } diff --git a/shared/ios/KeybaseShare/ShareViewController.swift b/shared/ios/KeybaseShare/ShareViewController.swift index 163e8a5e7668..e303025814bf 100644 --- a/shared/ios/KeybaseShare/ShareViewController.swift +++ b/shared/ios/KeybaseShare/ShareViewController.swift @@ -26,19 +26,15 @@ public class ShareViewController: UIViewController { func openApp() { let path = selectedConvID.map { "keybase://incoming-share/\($0)" } ?? "keybase://incoming-share" guard let url = URL(string: path) else { return } + let sel = #selector(UIApplication.open(_:options:completionHandler:)) var responder: UIResponder? = self while let r = responder { - if r.responds(to: #selector(UIApplication.openURL(_:))) { - do { - let sel = #selector(UIApplication.open(_:options:completionHandler:)) - if r.responds(to: sel) { - let imp = r.method(for: sel) - typealias Func = @convention(c) (AnyObject, Selector, URL, [UIApplication.OpenExternalURLOptionsKey: Any], ((Bool) -> Void)?) -> Void - let f = unsafeBitCast(imp, to: Func.self) - f(r, sel, url, [:], nil) - return - } - } + if r.responds(to: sel) { + let imp = r.method(for: sel) + typealias Func = @convention(c) (AnyObject, Selector, URL, [UIApplication.OpenExternalURLOptionsKey: Any], ((Bool) -> Void)?) -> Void + let f = unsafeBitCast(imp, to: Func.self) + f(r, sel, url, [:], nil) + return } responder = r.next } @@ -80,9 +76,8 @@ public class ShareViewController: UIViewController { ($0 as? NSExtensionItem)?.attachments } ?? [] - weak var weakSelf = self - iph = ItemProviderHelper(forShare: true, withItems: itemArrs) { - guard let self = weakSelf else { return } + iph = ItemProviderHelper(forShare: true, withItems: itemArrs) { [weak self] in + guard let self else { return } self.completeRequestAlreadyInMainThread() } showProgressView() diff --git a/shared/ios/Podfile.lock b/shared/ios/Podfile.lock index a0996a92717c..ba1142c1091a 100644 --- a/shared/ios/Podfile.lock +++ b/shared/ios/Podfile.lock @@ -3384,7 +3384,7 @@ SPEC CHECKSUMS: React-logger: a913317214a26565cd4c045347edf1bcacb80a3f React-Mapbuffer: 017336879e2e0fb7537bbc08c24f34e2384c9260 React-microtasksnativemodule: 63ee6730cec233feab9cdcc0c100dc28a12e4165 - react-native-kb: 078843e8c52d210aff0c50cbb4c4abe888c28ee2 + react-native-kb: 47269c30b862f82a1556f88cc6f00dbee91a9a98 react-native-keyboard-controller: a9e423beaa20d00a4b9664b0fa37f1cdf3a59975 react-native-netinfo: 9fad4eedfec9840a10e73ac4591ea1158523309b react-native-safe-area-context: 0a3b034bb63a5b684dd2f5fffd3c90ef6ed41ee8 diff --git a/shared/package.json b/shared/package.json index e6db987c71c5..74159c4dbd82 100644 --- a/shared/package.json +++ b/shared/package.json @@ -60,6 +60,7 @@ "android-unsigned": "react-native run-android --mode 'releaseUnsigned' --appId io.keybase.ossifrage.unsigned", "android-install-downgrade-unsigned": "adb install -r -d android/app/build/outputs/apk/releaseUnsigned/app-releaseUnsigned.apk", "android-logs": "adb logcat | grep $(adb shell pidof io.keybase.ossifrage)", + "android-logs-dump": "adb logcat -d --pid=$(adb shell pidof io.keybase.ossifrage)", "android-log-clear": "adb logcat -b all -c", "clean-and-install": "rm -rf node_modules ; yarn pod-clean ; yarn modules ; yarn pod-install", "coverage": "rm -rf coverage-ts; npx typescript-coverage-report",