Mobile: Upgrade to React Native 0.81 (#14232)

This commit is contained in:
Henry Heino
2026-02-04 02:03:02 -08:00
committed by GitHub
parent af2926b634
commit 00248a9177
652 changed files with 3985 additions and 2912 deletions
+5
View File
@@ -92,6 +92,7 @@ readme/
packages/react-native-vosk/lib/ packages/react-native-vosk/lib/
packages/lib/countable/Countable.js packages/lib/countable/Countable.js
packages/onenote-converter/renderer/pkg/* packages/onenote-converter/renderer/pkg/*
packages/whisper-voice-typing/lib/
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD # AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
packages/app-cli/app/LinkSelector.js packages/app-cli/app/LinkSelector.js
@@ -691,6 +692,7 @@ packages/app-mobile/components/FeedbackBanner.js
packages/app-mobile/components/FolderPicker.js packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js packages/app-mobile/components/IconButton.js
packages/app-mobile/components/KeyboardAvoidingView.js
packages/app-mobile/components/Modal.js packages/app-mobile/components/Modal.js
packages/app-mobile/components/ModalDialog.js packages/app-mobile/components/ModalDialog.js
packages/app-mobile/components/NestableFlatList.js packages/app-mobile/components/NestableFlatList.js
@@ -969,6 +971,7 @@ packages/app-mobile/utils/hooks/useSafeAreaPadding.js
packages/app-mobile/utils/image/fileToImage.web.js packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initReact.js
packages/app-mobile/utils/initializeCommandService.js packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
@@ -1942,4 +1945,6 @@ packages/tools/website/utils/pressCarousel.js
packages/tools/website/utils/processTranslations.js packages/tools/website/utils/processTranslations.js
packages/tools/website/utils/render.js packages/tools/website/utils/render.js
packages/tools/website/utils/types.js packages/tools/website/utils/types.js
packages/whisper-voice-typing/src/index.js
packages/whisper-voice-typing/src/specs/Whisper.nitro.js
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD # AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
+4
View File
@@ -665,6 +665,7 @@ packages/app-mobile/components/FeedbackBanner.js
packages/app-mobile/components/FolderPicker.js packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js packages/app-mobile/components/IconButton.js
packages/app-mobile/components/KeyboardAvoidingView.js
packages/app-mobile/components/Modal.js packages/app-mobile/components/Modal.js
packages/app-mobile/components/ModalDialog.js packages/app-mobile/components/ModalDialog.js
packages/app-mobile/components/NestableFlatList.js packages/app-mobile/components/NestableFlatList.js
@@ -943,6 +944,7 @@ packages/app-mobile/utils/hooks/useSafeAreaPadding.js
packages/app-mobile/utils/image/fileToImage.web.js packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initReact.js
packages/app-mobile/utils/initializeCommandService.js packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
@@ -1916,5 +1918,7 @@ packages/tools/website/utils/pressCarousel.js
packages/tools/website/utils/processTranslations.js packages/tools/website/utils/processTranslations.js
packages/tools/website/utils/render.js packages/tools/website/utils/render.js
packages/tools/website/utils/types.js packages/tools/website/utils/types.js
packages/whisper-voice-typing/src/index.js
packages/whisper-voice-typing/src/specs/Whisper.nitro.js
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD # AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
+1
View File
@@ -5,6 +5,7 @@
"exceptions": [ "exceptions": [
"@joplin/editor", "@joplin/editor",
"@joplin/fork-htmlparser2", "@joplin/fork-htmlparser2",
"@joplin/whisper-voice-typing",
"@joplin/fork-sax", "@joplin/fork-sax",
"@joplin/fork-uslug", "@joplin/fork-uslug",
"@joplin/htmlpack", "@joplin/htmlpack",
+1 -1
View File
@@ -33,7 +33,7 @@
"/packages/app-desktop/build/", "/packages/app-desktop/build/",
"/packages/app-desktop/utils/checkForUpdatesUtilsTestData.ts", "/packages/app-desktop/utils/checkForUpdatesUtilsTestData.ts",
"/packages/app-desktop/vendor/", "/packages/app-desktop/vendor/",
"/packages/app-mobile/android/vendor/", "/packages/whisper-voice-typing/vendor/",
"/packages/app-mobile/ios/Pods/", "/packages/app-mobile/ios/Pods/",
"/packages/app-mobile/lib/rnInjectedJs", "/packages/app-mobile/lib/rnInjectedJs",
"/packages/app-mobile/pluginAssets", "/packages/app-mobile/pluginAssets",
+20 -35
View File
@@ -2,11 +2,24 @@ apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android" apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react" apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/** /**
* This is the configuration block to customize your React Native Android app. * This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need. * By default you don't need to apply any configuration, just uncomment the lines you need.
*/ */
react { react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// (Disabled) Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
// cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
// bundleCommand = "export:embed"
/* Folders */ /* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..' // The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../..") // root = file("../..")
@@ -55,31 +68,12 @@ react {
} }
/** /**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode. * Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/ */
def enableProguardInReleaseBuilds = false def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android { android {
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
version '3.22.1'
}
}
ndkVersion rootProject.ext.ndkVersion ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion compileSdk rootProject.ext.compileSdkVersion
@@ -91,19 +85,15 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097788 versionCode 2097788
versionName "3.6.0" versionName "3.6.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
ndk { ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64" abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
} }
// Needed to fix: The number of method references in a .dex file cannot exceed 64K // Needed to fix: The number of method references in a .dex file cannot exceed 64K
multiDexEnabled true multiDexEnabled true
externalNativeBuild {
cmake {
cppFlags '-DCMAKE_BUILD_TYPE=Release'
// For 16 KB pages. This should be removable after upgrading to NDK r28
arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
}
}
} }
signingConfigs { signingConfigs {
debug { debug {
@@ -129,7 +119,7 @@ android {
// Caution! In production, you need to generate your own keystore file. // Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android. // see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.release signingConfig signingConfigs.release
minifyEnabled enableProguardInReleaseBuilds minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
} }
profileable { profileable {
@@ -149,10 +139,5 @@ android {
dependencies { dependencies {
// The version of react-native is set by the React Native Gradle Plugin // The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android") implementation("com.facebook.react:react-android")
implementation("com.facebook.react:hermes-android")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
} }
+4
View File
@@ -8,3 +8,7 @@
# http://developer.android.com/guide/developing/tools/proguard.html # http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here: # Add any project specific keep options here:
# Keep classes referenced by JNI
# (see https://developer.android.com/topic/performance/app-optimization/add-keep-rules)
-keep class com.margelo.nitro.whispervoicetyping.AudioRecorder
@@ -44,6 +44,7 @@
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:resizeableActivity="true" android:resizeableActivity="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true"> android:supportsRtl="true">
<!-- Enable profiling in release builds (Android 10+) --> <!-- Enable profiling in release builds (Android 10+) -->
@@ -1,65 +0,0 @@
# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html.
# For more examples on how to use CMake, see https://github.com/android/ndk-samples.
# Sets the minimum CMake version required for this project.
cmake_minimum_required(VERSION 3.22.1)
# Declares the project name. The project name can be accessed via ${ PROJECT_NAME},
# Since this is the top level CMakeLists.txt, the project name is also accessible
# with ${CMAKE_PROJECT_NAME} (both CMake variables are in-sync within the top level
# build script scope).
project("joplin")
# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.
#
# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define
# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME}
# is preferred for the same purpose.
#
# In order to load a library into your app from Java/Kotlin, you must call
# System.loadLibrary() and pass the name of the library defined here;
# for GameActivity/NativeActivity derived applications, the same library name must be
# used in the AndroidManifest.xml file.
add_library(${CMAKE_PROJECT_NAME} SHARED
# List C/C++ source files with relative paths to this CMakeLists.txt.
whisperWrapper.cpp
utils/WhisperSession.cpp
utils/findLongestSilence.cpp
utils/findLongestSilence_test.cpp
)
set(WHISPER_LIB_DIR ${CMAKE_SOURCE_DIR}/../../../../vendor/whisper.cpp)
# Based on the Whisper.cpp Android example:
set(SHARED_FLAGS "-O3 ")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${SHARED_FLAGS} ")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${SHARED_FLAGS} -fvisibility=hidden -fvisibility-inlines-hidden -ffunction-sections -fdata-sections")
# Whisper: See https://stackoverflow.com/a/76290722
add_subdirectory(${WHISPER_LIB_DIR} ./whisper)
# Directories for header files
target_include_directories(
${CMAKE_PROJECT_NAME}
PUBLIC
${PROJECT_BASE_DIR}/shared
${WHISPER_LIB_DIR}/include
)
# Specifies libraries CMake should link to your target library. You
# can link libraries from various origins, such as libraries defined in this
# build script, prebuilt third-party libraries, or Android system libraries.
target_link_libraries(${CMAKE_PROJECT_NAME}
whisper
# List libraries link to the target library
android
log
)
@@ -1,151 +0,0 @@
// Write C++ code here.
//
// Do not forget to dynamically load the C++ library into your application.
//
// For instance,
//
// In MainActivity.java:
// static {
// System.loadLibrary("joplin");
// }
//
// Or, in MainActivity.kt:
// companion object {
// init {
// System.loadLibrary("joplin")
// }
// }
#include <jni.h>
#include <memory>
#include <string>
#include <sstream>
#include <android/log.h>
#include "whisper.h"
#include "utils/WhisperSession.h"
#include "utils/androidUtil.h"
#include "utils/findLongestSilence_test.h"
void log_android(enum ggml_log_level level, const char* message, void* user_data) {
android_LogPriority priority = level == 4 ? ANDROID_LOG_ERROR : ANDROID_LOG_INFO;
__android_log_print(priority, "Whisper::JNI::cpp", "%s", message);
}
jstring stringToJava(JNIEnv *env, const std::string& source) {
return env->NewStringUTF(source.c_str());
}
std::string stringToCXX(JNIEnv *env, jstring jString) {
const char *jStringChars = env->GetStringUTFChars(jString, nullptr);
std::string result { jStringChars };
env->ReleaseStringUTFChars(jString, jStringChars);
return result;
}
void throwException(JNIEnv *env, const std::string& message) {
jclass errorClass = env->FindClass("java/lang/Exception");
env->ThrowNew(errorClass, message.c_str());
}
extern "C"
JNIEXPORT jlong JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_init(
JNIEnv *env,
jobject thiz,
jstring modelPath,
jstring language,
jstring prompt,
jboolean useShortAudioContext
) {
whisper_log_set(log_android, nullptr);
try {
auto *pSession = new WhisperSession(
stringToCXX(env, modelPath), stringToCXX(env, language), stringToCXX(env, prompt), useShortAudioContext
);
return (jlong) pSession;
} catch (const std::exception& exception) {
LOGW("Failed to init whisper: %s", exception.what());
throwException(env, exception.what());
return 0;
}
}
extern "C"
JNIEXPORT void JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_free(JNIEnv *env, jobject thiz,
jlong pointer) {
delete reinterpret_cast<WhisperSession *>(pointer);
}
extern "C"
JNIEXPORT void JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_addAudio(JNIEnv *env,
jobject thiz,
jlong pointer,
jfloatArray audio_data) {
auto *pSession = reinterpret_cast<WhisperSession *> (pointer);
jfloat *pAudioData = env->GetFloatArrayElements(audio_data, nullptr);
jsize lenAudioData = env->GetArrayLength(audio_data);
std::string result;
try {
pSession->addAudio(pAudioData, lenAudioData);
} catch (const std::exception& exception) {
LOGW("Failed to add to audio buffer: %s", exception.what());
throwException(env, exception.what());
}
// JNI_ABORT: "free the buffer without copying back the possible changes", pass 0 to copy
// changes (there should be no changes)
env->ReleaseFloatArrayElements(audio_data, pAudioData, JNI_ABORT);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_transcribeNextChunk(JNIEnv *env,
jobject thiz,
jlong pointer) {
auto *pSession = reinterpret_cast<WhisperSession *> (pointer);
std::string result;
try {
result = pSession->transcribeNextChunk();
} catch (const std::exception& exception) {
LOGW("Failed to run whisper: %s", exception.what());
throwException(env, exception.what());
return nullptr;
}
return stringToJava(env, result);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_transcribeRemaining(JNIEnv *env,
jobject thiz,
jlong pointer) {
auto *pSession = reinterpret_cast<WhisperSession *> (pointer);
std::string result;
try {
result = pSession->transcribeAll();
} catch (const std::exception& exception) {
LOGW("Failed to run whisper: %s", exception.what());
throwException(env, exception.what());
return nullptr;
}
return stringToJava(env, result);
}
extern "C"
JNIEXPORT void JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_runTests(JNIEnv *env, jobject thiz) {
try {
findLongestSilence_test();
} catch (const std::exception& exception) {
LOGW("Failed to run tests: %s", exception.what());
throwException(env, exception.what());
}
}
@@ -6,37 +6,36 @@ import expo.modules.ReactNativeHostWrapper
import android.app.Application import android.app.Application
import com.facebook.react.PackageList import com.facebook.react.PackageList
import com.facebook.react.ReactApplication import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactHost import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeHost import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import net.cozic.joplin.audio.SpeechToTextPackage
import net.cozic.joplin.versioninfo.SystemVersionInformationPackage import net.cozic.joplin.versioninfo.SystemVersionInformationPackage
import net.cozic.joplin.share.SharePackage import net.cozic.joplin.share.SharePackage
import net.cozic.joplin.ssl.SslPackage import net.cozic.joplin.ssl.SslPackage
class MainApplication : Application(), ReactApplication { class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(this, object : DefaultReactNativeHost(this) { override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
override fun getPackages(): List<ReactPackage> = this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply { PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example: // Packages that cannot be autolinked yet can be added manually here, for example:
add(SharePackage()) add(SharePackage())
add(SslPackage()) add(SslPackage())
add(SystemVersionInformationPackage()) add(SystemVersionInformationPackage())
add(SpeechToTextPackage())
} }
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED })
})
override val reactHost: ReactHost override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(this.applicationContext, reactNativeHost) get() = ReactNativeHostWrapper.createReactHost(this.applicationContext, reactNativeHost)
@@ -44,16 +43,17 @@ class MainApplication : Application(), ReactApplication {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
SoLoader.init(this, OpenSourceMergedSoMapping) try {
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { DefaultNewArchitectureEntryPoint.releaseLevel = ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
// If you opted-in for the New Architecture, we load the native entry point for this app. } catch (e: IllegalArgumentException) {
load() DefaultNewArchitectureEntryPoint.releaseLevel = ReleaseLevel.STABLE
} }
ApplicationLifecycleDispatcher.onApplicationCreate(this) loadReactNative(this)
} ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
} }
} }
@@ -1,5 +0,0 @@
package net.cozic.joplin.audio
class InvalidSessionIdException(id: Int) : IllegalArgumentException("Invalid session ID $id") {
}
@@ -1,64 +0,0 @@
package net.cozic.joplin.audio
import java.io.Closeable
class NativeWhisperLib(
modelPath: String,
languageCode: String,
prompt: String,
shortAudioContext: Boolean,
) : Closeable {
companion object {
init {
System.loadLibrary("joplin")
}
external fun runTests(): Unit;
// TODO: The example whisper.cpp project transfers pointers as Longs to the Kotlin code.
// This seems unsafe. Try changing how this is managed.
private external fun init(modelPath: String, languageCode: String, prompt: String, shortAudioContext: Boolean): Long;
private external fun free(pointer: Long): Unit;
private external fun addAudio(pointer: Long, audioData: FloatArray): Unit;
private external fun transcribeNextChunk(pointer: Long): String;
private external fun transcribeRemaining(pointer: Long): String;
}
private var closed = false
private val pointer: Long = init(modelPath, languageCode, prompt, shortAudioContext)
fun addAudio(audioData: FloatArray) {
if (closed) {
throw Exception("Cannot add audio data to a closed session")
}
Companion.addAudio(pointer, audioData)
}
fun transcribeNextChunk(): String {
if (closed) {
throw Exception("Cannot transcribe using a closed session")
}
return Companion.transcribeNextChunk(pointer)
}
fun transcribeRemaining(): String {
if (closed) {
throw Exception("Cannot transcribeAll using a closed session")
}
return Companion.transcribeRemaining(pointer)
}
override fun close() {
if (closed) {
throw Exception("Cannot close a whisper session twice")
}
closed = true
free(pointer)
}
}
@@ -1,62 +0,0 @@
package net.cozic.joplin.audio
import android.content.Context
import android.util.Log
import java.io.Closeable
class SpeechToTextConverter(
modelPath: String,
locale: String,
prompt: String,
useShortAudioCtx: Boolean,
recorderFactory: AudioRecorderFactory,
context: Context,
) : Closeable {
private val recorder = recorderFactory(context)
private val languageCode = Regex("_.*").replace(locale, "")
private var whisper = NativeWhisperLib(
modelPath,
languageCode,
prompt,
useShortAudioCtx,
)
fun start() {
recorder.start()
}
private fun convert(data: FloatArray): String {
Log.d("Whisper", "Pre-transcribe data of size ${data.size}")
whisper.addAudio(data)
val result = whisper.transcribeNextChunk()
Log.d("Whisper", "Post transcribe. Got $result")
return result;
}
fun dropFirstSeconds(seconds: Double) {
Log.i("Whisper", "Drop first seconds $seconds")
recorder.dropFirstSeconds(seconds)
}
val bufferLengthSeconds: Double get() = recorder.bufferLengthSeconds
fun convertNext(seconds: Double): String {
val buffer = recorder.pullNextSeconds(seconds)
val result = convert(buffer)
dropFirstSeconds(seconds)
return result
}
// Converts as many seconds of buffered data as possible, without waiting
fun convertRemaining(): String {
val buffer = recorder.pullAvailable()
whisper.addAudio(buffer)
return whisper.transcribeRemaining()
}
override fun close() {
Log.d("Whisper", "Close")
recorder.close()
whisper.close()
}
}
@@ -1,87 +0,0 @@
package net.cozic.joplin.audio
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.uimanager.ViewManager
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class SpeechToTextPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf<NativeModule>(SpeechToTextModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
class SpeechToTextModule(
private var context: ReactApplicationContext,
) : ReactContextBaseJavaModule(context), LifecycleEventListener {
private val executorService: ExecutorService = Executors.newFixedThreadPool(1)
private val sessionManager = SpeechToTextSessionManager(executorService)
override fun getName() = "SpeechToTextModule"
override fun onHostResume() { }
override fun onHostPause() { }
override fun onHostDestroy() { }
@ReactMethod
fun runTests(promise: Promise) {
try {
NativeWhisperLib.runTests()
promise.resolve(true)
} catch (exception: Throwable) {
promise.reject(exception)
}
}
@ReactMethod
fun openSession(modelPath: String, locale: String, prompt: String, useShortAudioCtx: Boolean, promise: Promise) {
val appContext = context.applicationContext
try {
val sessionId = sessionManager.openSession(modelPath, locale, prompt, useShortAudioCtx, appContext)
promise.resolve(sessionId)
} catch (exception: Throwable) {
promise.reject(exception)
}
}
@ReactMethod
fun startRecording(sessionId: Int, promise: Promise) {
sessionManager.startRecording(sessionId, promise)
}
@ReactMethod
fun getBufferLengthSeconds(sessionId: Int, promise: Promise) {
sessionManager.getBufferLengthSeconds(sessionId, promise)
}
@ReactMethod
fun dropFirstSeconds(sessionId: Int, duration: Double, promise: Promise) {
sessionManager.dropFirstSeconds(sessionId, duration, promise)
}
@ReactMethod
fun convertNext(sessionId: Int, duration: Double, promise: Promise) {
sessionManager.convertNext(sessionId, duration, promise)
}
@ReactMethod
fun convertAvailable(sessionId: Int, promise: Promise) {
sessionManager.convertAvailable(sessionId, promise)
}
@ReactMethod
fun closeSession(sessionId: Int, promise: Promise) {
sessionManager.closeSession(sessionId, promise)
}
}
}
@@ -1,111 +0,0 @@
package net.cozic.joplin.audio
import android.content.Context
import com.facebook.react.bridge.Promise
import java.util.concurrent.Executor
import java.util.concurrent.locks.ReentrantLock
class SpeechToTextSession (
val converter: SpeechToTextConverter
) {
val mutex = ReentrantLock()
}
class SpeechToTextSessionManager(
private var executor: Executor,
) {
private val sessions: MutableMap<Int, SpeechToTextSession> = mutableMapOf()
private var nextSessionId: Int = 0
fun openSession(
modelPath: String,
locale: String,
prompt: String,
useShortAudioCtx: Boolean,
context: Context,
): Int {
val sessionId = nextSessionId++
sessions[sessionId] = SpeechToTextSession(
SpeechToTextConverter(
modelPath, locale, prompt, useShortAudioCtx, recorderFactory = AudioRecorder.factory, context,
)
)
return sessionId
}
private fun getSession(id: Int): SpeechToTextSession {
return sessions[id] ?: throw InvalidSessionIdException(id)
}
private fun concurrentWithSession(
id: Int,
callback: (session: SpeechToTextSession)->Unit,
) {
executor.execute {
val session = getSession(id)
session.mutex.lock()
try {
callback(session)
} finally {
session.mutex.unlock()
}
}
}
private fun concurrentWithSession(
id: Int,
onError: (error: Throwable)->Unit,
callback: (session: SpeechToTextSession)->Unit,
) {
return concurrentWithSession(id) { session ->
try {
callback(session)
} catch (error: Throwable) {
onError(error)
}
}
}
fun startRecording(sessionId: Int, promise: Promise) {
this.concurrentWithSession(sessionId, promise::reject) { session ->
session.converter.start()
promise.resolve(null)
}
}
// Left-shifts the recording buffer by [duration] seconds
fun dropFirstSeconds(sessionId: Int, duration: Double, promise: Promise) {
this.concurrentWithSession(sessionId, promise::reject) { session ->
session.converter.dropFirstSeconds(duration)
promise.resolve(sessionId)
}
}
fun getBufferLengthSeconds(sessionId: Int, promise: Promise) {
this.concurrentWithSession(sessionId, promise::reject) { session ->
promise.resolve(session.converter.bufferLengthSeconds)
}
}
// Waits for the next [duration] seconds to become available, then converts
fun convertNext(sessionId: Int, duration: Double, promise: Promise) {
this.concurrentWithSession(sessionId, promise::reject) { session ->
val result = session.converter.convertNext(duration)
promise.resolve(result)
}
}
// Converts all available recorded data
fun convertAvailable(sessionId: Int, promise: Promise) {
this.concurrentWithSession(sessionId, promise::reject) { session ->
val result = session.converter.convertRemaining()
promise.resolve(result)
}
}
fun closeSession(sessionId: Int, promise: Promise) {
this.concurrentWithSession(sessionId) { session ->
session.converter.close()
promise.resolve(null)
}
}
}
+4 -4
View File
@@ -2,14 +2,14 @@
buildscript { buildscript {
ext { ext {
buildToolsVersion = "35.0.0" buildToolsVersion = "36.0.0"
minSdkVersion = 24 minSdkVersion = 24
compileSdkVersion = 35 compileSdkVersion = 36
targetSdkVersion = 35 targetSdkVersion = 36
ndkVersion = "27.1.12297006" ndkVersion = "27.1.12297006"
kotlinVersion = "2.0.21" kotlinVersion = "2.1.20"
} }
repositories { repositories {
google() google()
@@ -16,7 +16,7 @@ org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryEr
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the # AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK # Android operating system, and which are packaged with your app's APK
@@ -34,12 +34,17 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# your application. You should enable this flag either if you want # your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that # to write custom TurboModules/Fabric components OR use libraries that
# are providing them. # are providing them.
newArchEnabled=false newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine. # Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead. # If set to false, you will be using JSC instead.
hermesEnabled=true hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# To fix this error: # To fix this error:
# #
# > Failed to transform bcprov-jdk15on-1.68.jar # > Failed to transform bcprov-jdk15on-1.68.jar
Binary file not shown.
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+2 -2
View File
@@ -114,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.
+2 -2
View File
@@ -70,11 +70,11 @@ goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar set CLASSPATH=
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
@@ -1,9 +0,0 @@
whisper.cpp/.gitmodules
whisper.cpp/scripts/
whisper.cpp/samples/
whisper.cpp/tests/
whisper.cpp/models/
whisper.cpp/examples/
whisper.cpp/.*/
whisper.cpp/bindings/
whisper.cpp/**/*.Dockerfile
+5 -2
View File
@@ -1,4 +1,7 @@
{ {
"name": "Joplin", "name": "Joplin",
"displayName": "Joplin" "displayName": "Joplin",
} "plugins": [
"@react-native-community/datetimepicker"
]
}
@@ -44,7 +44,7 @@ const useStyles = (theme: ThemeStyle) => {
}, },
contentContainer: { contentContainer: {
padding: 20, padding: 20,
paddingBottom: 14, paddingBottom: 14 + safeAreaPadding.paddingBottom,
gap: 8, gap: 8,
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
@@ -13,6 +13,7 @@ import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
import { Props, WebViewControl } from './types'; import { Props, WebViewControl } from './types';
import useCss from './utils/useCss'; import useCss from './utils/useCss';
import { Platform } from 'react-native';
const logger = Logger.create('ExtendedWebView'); const logger = Logger.create('ExtendedWebView');
@@ -141,7 +142,8 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
onLoadEnd={props.onLoadEnd} onLoadEnd={props.onLoadEnd}
onContentProcessDidTerminate={refreshWebViewAfterCrash} onContentProcessDidTerminate={refreshWebViewAfterCrash}
onRenderProcessGone={refreshWebViewAfterCrash} onRenderProcessGone={refreshWebViewAfterCrash}
decelerationRate='normal' // See https://github.com/react-native-webview/react-native-webview/issues/3814
decelerationRate={Platform.OS === 'ios' ? 'normal' : undefined}
/> />
); );
}; };
@@ -0,0 +1,26 @@
import * as React from 'react';
import { KeyboardAvoidingViewProps, KeyboardAvoidingView as NativeKeyboardAvoidingView } from 'react-native';
import useKeyboardState from '../utils/hooks/useKeyboardState';
interface Props extends KeyboardAvoidingViewProps {}
const KeyboardAvoidingView: React.FC<Props> = ({ enabled, children, ...forwardedProps }) => {
const keyboardState = useKeyboardState();
enabled &&= (
// When the floating keyboard is enabled, the KeyboardAvoidingView can have a very small
// height. Don't use the KeyboardAvoidingView when the floating keyboard is enabled.
// See https://github.com/facebook/react-native/issues/29473
!keyboardState.isFloatingKeyboard
);
return <NativeKeyboardAvoidingView
behavior='padding'
{...forwardedProps}
enabled={enabled}
>
{children}
</NativeKeyboardAvoidingView>;
};
export default KeyboardAvoidingView;
+3 -2
View File
@@ -1,12 +1,13 @@
import * as React from 'react'; import * as React from 'react';
import { RefObject, useCallback, useMemo, useRef, useState } from 'react'; import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import { GestureResponderEvent, KeyboardAvoidingView, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native'; import { GestureResponderEvent, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native';
import FocusControl from './accessibility/FocusControl/FocusControl'; import FocusControl from './accessibility/FocusControl/FocusControl';
import { msleep, Second } from '@joplin/utils/time'; import { msleep, Second } from '@joplin/utils/time';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { ModalState } from './accessibility/FocusControl/types'; import { ModalState } from './accessibility/FocusControl/types';
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding'; import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import KeyboardAvoidingView from './KeyboardAvoidingView';
export interface ModalElementProps extends ModalProps { export interface ModalElementProps extends ModalProps {
children: React.ReactNode; children: React.ReactNode;
@@ -175,7 +176,7 @@ const ModalElement: React.FC<ModalElementProps> = ({
{...modalProps} {...modalProps}
> >
{scrollOverflow ? ( {scrollOverflow ? (
<KeyboardAvoidingView behavior='padding' style={styles.keyboardAvoidingView}> <KeyboardAvoidingView style={styles.keyboardAvoidingView} enabled={true}>
<ScrollView <ScrollView
{...extraScrollViewProps} {...extraScrollViewProps}
style={[styles.modalScrollView, extraScrollViewProps.style]} style={[styles.modalScrollView, extraScrollViewProps.style]}
@@ -61,7 +61,7 @@ const ProfileListItem: React.FC<ProfileItemProps> = ({ profile, profileConfig, s
} }
}; };
const switchProfileMessage = _('To switch the profile, the app is going to close and you will need to restart it.'); const switchProfileMessage = _('To switch the profile, the app is going to restart.');
if (shim.mobilePlatform() === 'web') { if (shim.mobilePlatform() === 'web') {
if (confirm(switchProfileMessage)) { if (confirm(switchProfileMessage)) {
void doIt(); void doIt();
@@ -688,8 +688,8 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
const menuComp = const menuComp =
!menuOptions.length || !showContextMenuButton ? null : ( !menuOptions.length || !showContextMenuButton ? null : (
<Menu themeId={this.props.themeId} options={menuOptions}> <Menu themeId={this.props.themeId} options={menuOptions}>
<View style={contextMenuStyle} accessibilityLabel={_('Actions')}> <View style={contextMenuStyle}>
<Icon name="ionicon ellipsis-vertical" style={this.styles().contextMenuTrigger} accessibilityLabel={null}/> <Icon name="ionicon ellipsis-vertical" style={this.styles().contextMenuTrigger} accessibilityLabel={_('Actions')}/>
</View> </View>
</Menu> </Menu>
); );
+12 -2
View File
@@ -7,6 +7,7 @@ import AccessibleView from './accessibility/AccessibleView';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import useReduceMotionEnabled from '../utils/hooks/useReduceMotionEnabled'; import useReduceMotionEnabled from '../utils/hooks/useReduceMotionEnabled';
import { themeStyle } from './global-style'; import { themeStyle } from './global-style';
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
export enum SideMenuPosition { export enum SideMenuPosition {
Left = 'left', Left = 'left',
@@ -40,6 +41,8 @@ interface UseStylesProps {
const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStylesProps) => { const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStylesProps) => {
const { height: windowHeight, width: windowWidth } = useWindowDimensions(); const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const safeAreaInsets = useSafeAreaPadding();
return useMemo(() => { return useMemo(() => {
const theme = themeStyle(themeId); const theme = themeStyle(themeId);
return StyleSheet.create({ return StyleSheet.create({
@@ -53,7 +56,7 @@ const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStyl
contentOuterWrapper: { contentOuterWrapper: {
flexGrow: 1, flexGrow: 1,
flexShrink: 1, flexShrink: 1,
width: windowWidth, width: '100%',
height: windowHeight, height: windowHeight,
transform: [{ transform: [{
translateX: menuOpenFraction.interpolate({ translateX: menuOpenFraction.interpolate({
@@ -71,11 +74,18 @@ const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStyl
flexShrink: 1, flexShrink: 1,
}, },
menuWrapper: { menuWrapper: {
backgroundColor: theme.backgroundColor,
position: 'absolute', position: 'absolute',
top: 0, top: 0,
bottom: 0, bottom: 0,
width: menuWidth, width: menuWidth,
paddingLeft: isLeftMenu ? safeAreaInsets.paddingLeft : 0,
paddingRight: isLeftMenu ? 0 : safeAreaInsets.paddingRight,
paddingTop: safeAreaInsets.paddingTop,
paddingBottom: safeAreaInsets.paddingBottom,
// In React Native, RTL replaces `left` with `right` and `right` with `left`. // In React Native, RTL replaces `left` with `right` and `right` with `left`.
// As such, we need to reverse the normal direction in RTL mode. // As such, we need to reverse the normal direction in RTL mode.
...(isLeftMenu === !I18nManager.isRTL ? { ...(isLeftMenu === !I18nManager.isRTL ? {
@@ -107,7 +117,7 @@ const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStyl
width: windowWidth, width: windowWidth,
}, },
}); });
}, [themeId, isLeftMenu, windowWidth, windowHeight, menuWidth, menuOpenFraction]); }, [themeId, isLeftMenu, windowWidth, windowHeight, menuWidth, menuOpenFraction, safeAreaInsets]);
}; };
interface UseAnimationsProps { interface UseAnimationsProps {
+22 -12
View File
@@ -2,13 +2,16 @@ import * as React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import NotesScreen from './screens/Notes/Notes'; import NotesScreen from './screens/Notes/Notes';
import SearchScreen from './screens/SearchScreen'; import SearchScreen from './screens/SearchScreen';
import { KeyboardAvoidingView, Platform, View } from 'react-native'; import { Platform, View, StyleSheet } from 'react-native';
import { AppState } from '../utils/types'; import { AppState } from '../utils/types';
import { themeStyle } from './global-style'; import { themeStyle } from './global-style';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import useKeyboardState from '../utils/hooks/useKeyboardState'; import useKeyboardState from '../utils/hooks/useKeyboardState';
import usePrevious from '@joplin/lib/hooks/usePrevious'; import usePrevious from '@joplin/lib/hooks/usePrevious';
import FeedbackBanner from './FeedbackBanner'; import FeedbackBanner from './FeedbackBanner';
import { Theme } from '@joplin/lib/themes/type';
import { useMemo } from 'react';
import KeyboardAvoidingView from './KeyboardAvoidingView';
interface Props { interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -20,6 +23,15 @@ interface Props {
themeId: number; themeId: number;
} }
const useStyles = (theme: Theme) => {
return useMemo(() => {
return StyleSheet.create({
keyboardAvoidingView: { flex: 1, backgroundColor: theme.backgroundColor },
});
}, [theme]);
};
const AppNavComponent: React.FC<Props> = (props) => { const AppNavComponent: React.FC<Props> = (props) => {
const keyboardState = useKeyboardState(); const keyboardState = useKeyboardState();
const safeAreaPadding = useSafeAreaInsets(); const safeAreaPadding = useSafeAreaInsets();
@@ -50,20 +62,18 @@ const AppNavComponent: React.FC<Props> = (props) => {
const searchScreenLoaded = searchScreenVisible || (previousRouteName === 'Search' && route.routeName === 'Note'); const searchScreenLoaded = searchScreenVisible || (previousRouteName === 'Search' && route.routeName === 'Note');
const theme = themeStyle(props.themeId); const theme = themeStyle(props.themeId);
const styles = useStyles(theme);
const style = { flex: 1, backgroundColor: theme.backgroundColor }; const autocompletionBarPadding = keyboardState.keyboardVisible ? safeAreaPadding.top : 0;
// When the floating keyboard is enabled, the KeyboardAvoidingView can have a very small
// height. Don't use the KeyboardAvoidingView when the floating keyboard is enabled.
// See https://github.com/facebook/react-native/issues/29473
const keyboardAvoidingViewEnabled = !keyboardState.isFloatingKeyboard;
const autocompletionBarPadding = Platform.OS === 'ios' && keyboardState.keyboardVisible ? safeAreaPadding.top : 0;
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
enabled={keyboardAvoidingViewEnabled} style={styles.keyboardAvoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : null} enabled={
style={style} // Workaround: On Android 15 and 16, the main app content seems to auto-resize when the keyboard is shown.
// On earlier Android versions (and in modals), this does not seem to be the case.
(Platform.OS === 'android' && Platform.Version < 35)
|| Platform.OS === 'ios'
}
> >
<NotesScreen visible={notesScreenVisible} /> <NotesScreen visible={notesScreenVisible} />
{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />} {searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />}
@@ -16,6 +16,7 @@ import usePrevious from '@joplin/lib/hooks/usePrevious';
import PlatformImplementation from '../../services/plugins/PlatformImplementation'; import PlatformImplementation from '../../services/plugins/PlatformImplementation';
import AccessibleView from '../accessibility/AccessibleView'; import AccessibleView from '../accessibility/AccessibleView';
import useOnDevPluginsUpdated from './utils/useOnDevPluginsUpdated'; import useOnDevPluginsUpdated from './utils/useOnDevPluginsUpdated';
import { ViewStyle } from 'react-native';
const logger = Logger.create('PluginRunnerWebView'); const logger = Logger.create('PluginRunnerWebView');
@@ -98,6 +99,17 @@ interface Props {
themeId: number; themeId: number;
} }
// The WebView needs to have a non-zero size to be rendered by
// newer React Native versions. This style makes it visually hidden.
const hiddenStyle: ViewStyle = {
width: 1,
height: 1,
opacity: 0,
position: 'absolute',
top: 0,
zIndex: -1,
};
const PluginRunnerWebViewComponent: React.FC<Props> = props => { const PluginRunnerWebViewComponent: React.FC<Props> = props => {
const webviewRef = useRef<WebViewControl>(null); const webviewRef = useRef<WebViewControl>(null);
@@ -189,7 +201,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
}; };
return ( return (
<AccessibleView style={{ display: 'none' }} inert={true}> <AccessibleView style={hiddenStyle} inert={true}>
{renderWebView()} {renderWebView()}
</AccessibleView> </AccessibleView>
); );
+2 -4
View File
@@ -6,16 +6,14 @@
// So there's basically still a one way flux: React => SQLite => Redux => React // So there's basically still a one way flux: React => SQLite => Redux => React
import './utils/initReact';
import './utils/polyfills'; import './utils/polyfills';
import Root from './root';
import { LogBox } from 'react-native'; import { LogBox } from 'react-native';
import { registerRootComponent } from 'expo'; import { registerRootComponent } from 'expo';
// Allows loading image assets. See https://github.com/expo/expo/issues/31240 // Allows loading image assets. See https://github.com/expo/expo/issues/31240
import 'expo-asset'; import 'expo-asset';
import shim from '@joplin/lib/shim';
shim.setReact(require('react'));
const Root = require('./root').default;
// Seems JavaScript developers love adding warnings everywhere, even when these warnings can't be fixed // Seems JavaScript developers love adding warnings everywhere, even when these warnings can't be fixed
// or don't really matter. Because we want important warnings to actually be fixed, we disable // or don't really matter. Because we want important warnings to actually be fixed, we disable
+1
View File
@@ -1,4 +1,5 @@
import './utils/polyfills'; import './utils/polyfills';
import './utils/initReact';
import { AppRegistry } from 'react-native'; import { AppRegistry } from 'react-native';
import Root from './root'; import Root from './root';
import Setting from '@joplin/lib/models/Setting'; import Setting from '@joplin/lib/models/Setting';
@@ -35,6 +35,8 @@
</dict> </dict>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string></string> <string></string>
<key>RCTNewArchEnabled</key>
<true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>
@@ -321,6 +321,8 @@
files = ( files = (
); );
inputPaths = ( inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
); );
name = "Bundle React Native code and images"; name = "Bundle React Native code and images";
outputPaths = ( outputPaths = (
@@ -339,13 +341,12 @@
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ReactNativeFs/RNFS_PrivacyInfo.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/react-native-image-picker/RNImagePickerPrivacyInfo.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/react-native-image-picker/RNImagePickerPrivacyInfo.bundle",
"${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Brands.ttf", "${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Brands.ttf",
"${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Regular.ttf", "${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Regular.ttf",
@@ -359,13 +360,12 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNFS_PrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNImagePickerPrivacyInfo.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNImagePickerPrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf",
@@ -409,11 +409,15 @@
inputPaths = ( inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Joplin/Pods-Joplin-frameworks.sh", "${PODS_ROOT}/Target Support Files/Pods-Joplin/Pods-Joplin-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL", "${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
); );
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputPaths = ( outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -452,11 +456,16 @@
inputFileListPaths = ( inputFileListPaths = (
); );
inputPaths = ( inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/Joplin/Joplin.entitlements",
"$(SRCROOT)/Pods/Target Support Files/Pods-Joplin/expo-configure-project.sh",
); );
name = "[Expo] Configure project"; name = "[Expo] Configure project";
outputFileListPaths = ( outputFileListPaths = (
); );
outputPaths = ( outputPaths = (
"$(SRCROOT)/Pods/Target Support Files/Pods-Joplin/ExpoModulesProvider.swift",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
@@ -653,6 +662,7 @@
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true; USE_HERMES = true;
}; };
name = Debug; name = Debug;
@@ -729,6 +739,7 @@
); );
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true; USE_HERMES = true;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
+12 -11
View File
@@ -44,16 +44,15 @@
<false/> <false/>
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
<true/> <true/>
<!-- Left over from before upgrading from RN 0.71, 0.73 --> <!-- Left over from before upgrading from RN 0.71, 0.73 -->
<key>NSExceptionDomains</key> <key>NSExceptionDomains</key>
<dict> <dict>
<key>localhost</key> <key>api.joplincloud.local</key>
<dict> <dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key> <key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/> <true/>
</dict> </dict>
<key>api.joplincloud.local</key> <key>localhost</key>
<dict> <dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key> <key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/> <true/>
@@ -62,18 +61,22 @@
</dict> </dict>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>To allow attaching a photo to a note</string> <string>To allow attaching a photo to a note</string>
<key>NSFaceIDUsageDescription</key>
<string>$(PRODUCT_NAME) requires FaceID access to secure access to the application</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>To add geo-location information to a note. Can be disabled in app.</string> <string>To add geo-location information to a note. Can be disabled in app.</string>
<key>NSLocationAlwaysUsageDescription</key> <key>NSLocationAlwaysUsageDescription</key>
<string>To add geo-location information to a note. Can be disabled in app.</string> <string>To add geo-location information to a note. Can be disabled in app.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>To add geo-location information to a note. Can be disabled in app.</string> <string>To add geo-location information to a note. Can be disabled in app.</string>
<key>NSMicrophoneUsageDescription</key>
<string>To allow attaching voice recordings to a note</string>
<key>NSPhotoLibraryAddUsageDescription</key> <key>NSPhotoLibraryAddUsageDescription</key>
<string>The images will be displayed on your notes.</string> <string>The images will be displayed on your notes.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>To allow attaching images to a note</string> <string>To allow attaching images to a note</string>
<key>NSMicrophoneUsageDescription</key> <key>RCTNewArchEnabled</key>
<string>To allow attaching voice recordings to a note</string> <true/>
<key>UIAppFonts</key> <key>UIAppFonts</key>
<array> <array>
<string>AntDesign.ttf</string> <string>AntDesign.ttf</string>
@@ -86,6 +89,10 @@
<string>MaterialDesignIcons.ttf</string> <string>MaterialDesignIcons.ttf</string>
<string>MaterialCommunityIcons.ttf</string> <string>MaterialCommunityIcons.ttf</string>
</array> </array>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>
@@ -109,11 +116,5 @@
<string>Automatic</string> <string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <false/>
<key>NSFaceIDUsageDescription</key>
<string>$(PRODUCT_NAME) requires FaceID access to secure access to the application</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict> </dict>
</plist> </plist>
+21 -10
View File
@@ -5,23 +5,33 @@ require File.join(File.dirname(`node --print "require.resolve('react-native/pack
require 'json' require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {} podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
ENV['RCT_NEW_ARCH_ENABLED'] = '0' if podfile_properties['newArchEnabled'] == 'false' def ccache_enabled?(podfile_properties)
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR'] # Environment variable takes precedence
return ENV['USE_CCACHE'] == '1' if ENV['USE_CCACHE']
# Fall back to Podfile properties
podfile_properties['apple.ccacheEnabled'] == 'true'
end
ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1' platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
install! 'cocoapods',
:deterministic_uuids => false
prepare_react_native_project! prepare_react_native_project!
target 'Joplin' do target 'Joplin' do
use_expo_modules! use_expo_modules!
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1' if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] != '0'
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"]; config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
else else
config_command = [ config_command = [
'npx', 'node',
'--no-warnings',
'--eval',
'require(\'expo/bin/autolinking\')',
'expo-modules-autolinking', 'expo-modules-autolinking',
'react-native-config', 'react-native-config',
'--json', '--json',
@@ -35,13 +45,14 @@ target 'Joplin' do
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS'] use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
use_react_native!( use_react_native!(
:path => config[:reactNativePath], :path => config[:reactNativePath],
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
# An absolute path to your application root. # An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/.." :app_path => "#{Pod::Config.instance.installation_root}/..",
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
) )
pod 'JoplinRNShareExtension', :path => 'ShareExtension' pod 'JoplinRNShareExtension', :path => 'ShareExtension'
post_install do |installer| post_install do |installer|
@@ -50,7 +61,7 @@ target 'Joplin' do
installer, installer,
config[:reactNativePath], config[:reactNativePath],
:mac_catalyst_enabled => false, :mac_catalyst_enabled => false,
# :ccache_enabled => true :ccache_enabled => ccache_enabled?(podfile_properties),
) )
end end
end end
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,3 @@
{ {
"newArchEnabled": "false" "newArchEnabled": "true"
} }
@@ -43,5 +43,7 @@
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string> <string>com.apple.share-services</string>
</dict> </dict>
<key>RCTNewArchEnabled</key>
<true/>
</dict> </dict>
</plist> </plist>
+2 -1
View File
@@ -84,6 +84,7 @@ const emptyMockPackages = [
'@joplin/react-native-saf-x', '@joplin/react-native-saf-x',
'expo-av', 'expo-av',
'expo-av/build/Audio', 'expo-av/build/Audio',
'expo-image-manipulator',
]; ];
for (const packageName of emptyMockPackages) { for (const packageName of emptyMockPackages) {
jest.doMock(packageName, () => { jest.doMock(packageName, () => {
@@ -130,7 +131,7 @@ mockIconLibrary('@react-native-vector-icons/fontawesome5', 'FontAwesome5');
// Use a temporary folder instead. // Use a temporary folder instead.
const tempDirectoryPath = path.join(tmpdir(), `appmobile-test-${uuid.createNano()}`); const tempDirectoryPath = path.join(tmpdir(), `appmobile-test-${uuid.createNano()}`);
jest.doMock('react-native-fs', () => { jest.doMock('@dr.pogodin/react-native-fs', () => {
return { return {
CachesDirectoryPath: tempDirectoryPath, CachesDirectoryPath: tempDirectoryPath,
}; };
+1
View File
@@ -23,6 +23,7 @@ const localPackages = {
'@joplin/tools': path.resolve(__dirname, '../tools/'), '@joplin/tools': path.resolve(__dirname, '../tools/'),
'@joplin/utils': path.resolve(__dirname, '../utils/'), '@joplin/utils': path.resolve(__dirname, '../utils/'),
'@joplin/fork-htmlparser2': path.resolve(__dirname, '../fork-htmlparser2/'), '@joplin/fork-htmlparser2': path.resolve(__dirname, '../fork-htmlparser2/'),
'@joplin/whisper-voice-typing': path.resolve(__dirname, '../whisper-voice-typing/'),
'@joplin/fork-uslug': path.resolve(__dirname, '../fork-uslug/'), '@joplin/fork-uslug': path.resolve(__dirname, '../fork-uslug/'),
'@joplin/react-native-saf-x': path.resolve(__dirname, '../react-native-saf-x/'), '@joplin/react-native-saf-x': path.resolve(__dirname, '../react-native-saf-x/'),
'@joplin/react-native-alarm-notification': path.resolve(__dirname, '../react-native-alarm-notification/'), '@joplin/react-native-alarm-notification': path.resolve(__dirname, '../react-native-alarm-notification/'),
+23 -22
View File
@@ -21,13 +21,14 @@
"postinstall": "jetify" "postinstall": "jetify"
}, },
"dependencies": { "dependencies": {
"@bam.tech/react-native-image-resizer": "3.0.11", "@dr.pogodin/react-native-fs": "2.36.2",
"@joplin/editor": "~3.6", "@joplin/editor": "~3.6",
"@joplin/lib": "~3.6", "@joplin/lib": "~3.6",
"@joplin/react-native-alarm-notification": "~3.6", "@joplin/react-native-alarm-notification": "~3.6",
"@joplin/react-native-saf-x": "~3.6", "@joplin/react-native-saf-x": "~3.6",
"@joplin/renderer": "~3.6", "@joplin/renderer": "~3.6",
"@joplin/utils": "~3.6", "@joplin/utils": "~3.6",
"@joplin/whisper-voice-typing": "~3.6",
"@js-draw/material-icons": "1.33.0", "@js-draw/material-icons": "1.33.0",
"@react-native-clipboard/clipboard": "1.16.3", "@react-native-clipboard/clipboard": "1.16.3",
"@react-native-community/datetimepicker": "8.4.7", "@react-native-community/datetimepicker": "8.4.7",
@@ -47,40 +48,41 @@
"crypto-browserify": "3.12.1", "crypto-browserify": "3.12.1",
"deprecated-react-native-prop-types": "5.0.0", "deprecated-react-native-prop-types": "5.0.0",
"events": "3.3.0", "events": "3.3.0",
"expo": "53.0.23", "expo": "54.0.31",
"expo-av": "15.1.7", "expo-av": "16.0.8",
"expo-camera": "16.1.11", "expo-camera": "17.0.10",
"expo-local-authentication": "16.0.5", "expo-image-manipulator": "14.0.8",
"expo-local-authentication": "17.0.8",
"js-draw": "1.33.0", "js-draw": "1.33.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"md5": "2.3.0", "md5": "2.3.0",
"path-browserify": "1.0.1", "path-browserify": "1.0.1",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"punycode": "2.3.1", "punycode": "2.3.1",
"react": "19.0.0", "react": "19.1.0",
"react-native": "0.79.2", "react-native": "0.81.5",
"react-native-device-info": "14.1.1", "react-native-device-info": "14.1.1",
"react-native-dropdownalert": "5.2.0", "react-native-dropdownalert": "5.2.0",
"react-native-exit-app": "2.0.0",
"react-native-file-viewer": "2.1.5", "react-native-file-viewer": "2.1.5",
"react-native-fs": "2.20.0",
"react-native-get-random-values": "1.11.0", "react-native-get-random-values": "1.11.0",
"react-native-image-picker": "8.2.1", "react-native-image-picker": "8.2.1",
"react-native-localize": "3.5.4", "react-native-localize": "3.5.4",
"react-native-modal-datetime-picker": "18.0.0", "react-native-modal-datetime-picker": "18.0.0",
"react-native-nitro-modules": "0.33.2",
"react-native-paper": "5.14.5", "react-native-paper": "5.14.5",
"react-native-popup-menu": "0.17.0", "react-native-popup-menu": "0.17.0",
"react-native-quick-actions": "0.3.13", "react-native-quick-actions": "0.3.13",
"react-native-quick-base64": "2.2.2",
"react-native-quick-crypto": "0.7.17", "react-native-quick-crypto": "0.7.17",
"react-native-rsa-native": "2.0.5", "react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "5.6.2", "react-native-safe-area-context": "5.6.2",
"react-native-securerandom": "1.0.1", "react-native-securerandom": "1.0.1",
"react-native-share": "12.2.0", "react-native-share": "12.2.0",
"react-native-sqlite-storage": "6.0.1", "react-native-sqlite-storage": "6.0.1",
"react-native-svg": "15.13.0", "react-native-svg": "15.12.1",
"react-native-url-polyfill": "2.0.0", "react-native-url-polyfill": "2.0.0",
"react-native-version-info": "1.1.1", "react-native-version-info": "1.1.1",
"react-native-webview": "13.16.0", "react-native-webview": "13.15.0",
"react-native-zip-archive": "7.0.2", "react-native-zip-archive": "7.0.2",
"react-redux": "8.1.3", "react-redux": "8.1.3",
"redux": "4.2.1", "redux": "4.2.1",
@@ -101,18 +103,18 @@
"@joplin/turndown": "~4.0.80", "@joplin/turndown": "~4.0.80",
"@joplin/turndown-plugin-gfm": "~1.0.62", "@joplin/turndown-plugin-gfm": "~1.0.62",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.0",
"@react-native-community/cli": "16.0.3", "@react-native-community/cli": "20.0.0",
"@react-native-community/cli-platform-android": "16.0.3", "@react-native-community/cli-platform-android": "20.0.0",
"@react-native-community/cli-platform-ios": "16.0.3", "@react-native-community/cli-platform-ios": "20.0.0",
"@react-native/babel-preset": "0.80.1", "@react-native/babel-preset": "0.81.5",
"@react-native/metro-config": "0.79.5", "@react-native/metro-config": "0.81.5",
"@react-native/typescript-config": "0.80.2", "@react-native/typescript-config": "0.81.5",
"@sqlite.org/sqlite-wasm": "3.46.0-build2", "@sqlite.org/sqlite-wasm": "3.46.0-build2",
"@testing-library/react-native": "13.2.0", "@testing-library/react-native": "13.2.0",
"@types/fs-extra": "11.0.4", "@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/node": "18.19.130", "@types/node": "18.19.130",
"@types/react": "19.0.14", "@types/react": "19.1.10",
"@types/react-redux": "7.1.33", "@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.164", "@types/serviceworker": "0.0.164",
"@types/tar-stream": "3.1.4", "@types/tar-stream": "3.1.4",
@@ -130,10 +132,10 @@
"jsdom": "26.1.0", "jsdom": "26.1.0",
"nodemon": "3.1.10", "nodemon": "3.1.10",
"punycode": "2.3.1", "punycode": "2.3.1",
"react-dom": "19.0.0", "react-dom": "19.1.0",
"react-native-web": "0.21.2", "react-native-web": "0.21.2",
"react-refresh": "0.18.0", "react-refresh": "0.18.0",
"react-test-renderer": "19.0.0", "react-test-renderer": "19.1.0",
"sharp": "0.34.4", "sharp": "0.34.4",
"sqlite3": "5.1.6", "sqlite3": "5.1.6",
"timers-browserify": "2.0.12", "timers-browserify": "2.0.12",
@@ -147,7 +149,7 @@
"webpack-dev-server": "5.2.2" "webpack-dev-server": "5.2.2"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=20"
}, },
"expo": { "expo": {
"autolinking": { "autolinking": {
@@ -157,7 +159,6 @@
}, },
"install": { "install": {
"exclude": [ "exclude": [
"react-native@~0.76.6",
"react-native-reanimated@~3.16.1", "react-native-reanimated@~3.16.1",
"react-native-gesture-handler@~2.20.0", "react-native-gesture-handler@~2.20.0",
"react-native-screens@~4.4.0", "react-native-screens@~4.4.0",
+2 -5
View File
@@ -1,8 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import shim from '@joplin/lib/shim';
import PerformanceLogger from '@joplin/lib/PerformanceLogger'; import PerformanceLogger from '@joplin/lib/PerformanceLogger';
shim.setReact(React);
PerformanceLogger.onAppStartBegin(); PerformanceLogger.onAppStartBegin();
import setupQuickActions from './setupQuickActions'; import setupQuickActions from './setupQuickActions';
@@ -695,12 +692,12 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
let disableSideMenuGestures = this.props.disableSideMenuGestures; let disableSideMenuGestures = this.props.disableSideMenuGestures;
if (this.props.routeName === 'Note') { if (this.props.routeName === 'Note') {
sideMenuContent = <SafeAreaView style={{ flex: 1, backgroundColor: theme.backgroundColor }}><SideMenuContentNote options={this.props.noteSideMenuOptions}/></SafeAreaView>; sideMenuContent = <SideMenuContentNote options={this.props.noteSideMenuOptions}/>;
menuPosition = SideMenuPosition.Right; menuPosition = SideMenuPosition.Right;
} else if (this.props.routeName === 'Config') { } else if (this.props.routeName === 'Config') {
disableSideMenuGestures = true; disableSideMenuGestures = true;
} else { } else {
sideMenuContent = <SafeAreaView style={{ flex: 1, backgroundColor: theme.backgroundColor }}><SideMenuContent/></SafeAreaView>; sideMenuContent = <SideMenuContent/>;
} }
const appNavInit = { const appNavInit = {
+4 -1
View File
@@ -71,7 +71,10 @@ const crypto: Crypto = {
digest: async (algorithm: Digest, data: Uint8Array) => { digest: async (algorithm: Digest, data: Uint8Array) => {
const hash = QuickCrypto.createHash(digestNameMap[algorithm]); const hash = QuickCrypto.createHash(digestNameMap[algorithm]);
hash.update(data); hash.update(
// Cast: hash.update accepts TypedArrays, despite its declared types
data as unknown as ArrayBuffer,
);
return hash.digest(); return hash.digest();
}, },
@@ -3,35 +3,29 @@ import whisper from './whisper';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { exists, mkdir, remove, writeFile } from 'fs-extra'; import { exists, mkdir, remove, writeFile } from 'fs-extra';
import Setting from '@joplin/lib/models/Setting'; import Setting from '@joplin/lib/models/Setting';
import { NativeModules } from 'react-native'; const { testing__lastPrompt } = require('@joplin/whisper-voice-typing');
const SpeechToTextModule = NativeModules.SpeechToTextModule;
jest.mock('react-native', () => {
const reactNative = jest.requireActual('react-native');
jest.mock('@joplin/whisper-voice-typing', () => {
let lastPrompt: string|null = null; let lastPrompt: string|null = null;
// Set properties on reactNative rather than creating a new object with
// {...reactNative, ...}. Creating a new object triggers deprecation warnings.
// See https://github.com/facebook/react-native/issues/28839.
reactNative.NativeModules.SpeechToTextModule = {
convertNext: () => 'Test. This is test output. Test!',
runTests: ()=> {},
openSession: jest.fn((_path, _locale, prompt) => {
lastPrompt = prompt;
const someId = 1234; return {
return someId; runTests: ()=> {},
openSession: jest.fn((options) => {
lastPrompt = options.prompt;
return {
open: jest.fn(),
close: jest.fn(),
convertNext: jest.fn(() => 'Test. This is test output. Test!'),
convertAvailable: jest.fn(() => ''),
};
}), }),
closeSession: jest.fn(), test: ()=>{},
startRecording: jest.fn(),
convertAvailable: jest.fn(() => ''),
testing__lastPrompt: () => { testing__lastPrompt: () => {
return lastPrompt; return lastPrompt;
}, },
}; };
return reactNative;
}); });
interface ModelConfig { interface ModelConfig {
@@ -135,6 +129,6 @@ describe('whisper', () => {
}); });
await session.start(); await session.start();
expect(SpeechToTextModule.testing__lastPrompt()).toBe(expectedPrompt); expect(testing__lastPrompt()).toBe(expectedPrompt);
}); });
}); });
@@ -3,14 +3,13 @@ import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
import { rtrimSlashes } from '@joplin/utils/path'; import { rtrimSlashes } from '@joplin/utils/path';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { NativeModules } from 'react-native'; import { openSession, test as testWhisper, Session as WhisperSession } from '@joplin/whisper-voice-typing';
import { SpeechToTextCallbacks, VoiceTypingProvider, VoiceTypingSession } from './VoiceTyping'; import { SpeechToTextCallbacks, VoiceTypingProvider, VoiceTypingSession } from './VoiceTyping';
import { languageCodeOnly, stringByLocale } from '@joplin/lib/locale'; import { languageCodeOnly, stringByLocale } from '@joplin/lib/locale';
import { Platform } from 'react-native';
const logger = Logger.create('voiceTyping/whisper'); const logger = Logger.create('voiceTyping/whisper');
const { SpeechToTextModule } = NativeModules;
class WhisperConfig { class WhisperConfig {
public prompts: Map<string, string> = new Map(); public prompts: Map<string, string> = new Map();
public supportsShortAudioCtx = false; public supportsShortAudioCtx = false;
@@ -86,7 +85,7 @@ class Whisper implements VoiceTypingSession {
private isFirstParagraph = true; private isFirstParagraph = true;
public constructor( public constructor(
private sessionId: number|null, private session: WhisperSession|null,
private callbacks: SpeechToTextCallbacks, private callbacks: SpeechToTextCallbacks,
private config: WhisperConfig, private config: WhisperConfig,
) { ) {
@@ -124,18 +123,18 @@ class Whisper implements VoiceTypingSession {
} }
public async start() { public async start() {
if (this.sessionId === null) { if (this.session === null) {
throw new Error('Session closed.'); throw new Error('Session closed.');
} }
try { try {
logger.debug('starting recorder'); logger.debug('starting recorder');
await SpeechToTextModule.startRecording(this.sessionId); await this.session.open();
logger.debug('recorder started'); logger.debug('recorder started');
const loopStartCounter = this.closeCounter; const loopStartCounter = this.closeCounter;
while (this.closeCounter === loopStartCounter && this.sessionId !== null) { while (this.closeCounter === loopStartCounter && this.session !== null) {
logger.debug('reading block'); logger.debug('reading block');
const data: string = await SpeechToTextModule.convertNext(this.sessionId, 4); const data: string = await this.session.convertNext(4);
this.onDataFinalize(data); this.onDataFinalize(data);
logger.debug('done reading block. Length', data?.length); logger.debug('done reading block. Length', data?.length);
@@ -148,13 +147,13 @@ class Whisper implements VoiceTypingSession {
} }
public async stop() { public async stop() {
if (this.sessionId === null) { if (this.session === null) {
logger.debug('Session already closed.'); logger.debug('Session already closed.');
return; return;
} }
try { try {
const data: string = await SpeechToTextModule.convertAvailable(this.sessionId); const data: string = await this.session.convertNext(null);
this.onDataFinalize(data); this.onDataFinalize(data);
} catch (error) { } catch (error) {
logger.error('Error stopping session: ', error); logger.error('Error stopping session: ', error);
@@ -164,17 +163,17 @@ class Whisper implements VoiceTypingSession {
} }
public cancel() { public cancel() {
if (this.sessionId === null) { if (this.session === null) {
logger.debug('No session to cancel.'); logger.debug('No session to cancel.');
return; return Promise.resolve();
} }
logger.info('Closing session...'); logger.info('Closing session...');
const sessionId = this.sessionId; this.session.close();
this.sessionId = null; this.session = null;
this.closeCounter ++; this.closeCounter ++;
return SpeechToTextModule.closeSession(sessionId); return Promise.resolve();
} }
} }
@@ -213,7 +212,7 @@ const modelLocalFilepath = () => {
}; };
const whisper: VoiceTypingProvider = { const whisper: VoiceTypingProvider = {
supported: () => !!SpeechToTextModule && Setting.value('buildFlag.voiceTypingEnabled'), supported: () => Platform.OS === 'android' && Setting.value('buildFlag.voiceTypingEnabled'),
modelLocalFilepath: modelLocalFilepath, modelLocalFilepath: modelLocalFilepath,
getDownloadUrl: (locale) => { getDownloadUrl: (locale) => {
const lang = languageCodeOnly(locale).toLowerCase(); const lang = languageCodeOnly(locale).toLowerCase();
@@ -251,7 +250,7 @@ const whisper: VoiceTypingProvider = {
if (Setting.value('env') === Env.Dev) { if (Setting.value('env') === Env.Dev) {
try { try {
await SpeechToTextModule.runTests(); await testWhisper();
} catch (error) { } catch (error) {
logger.error('Testing error', error); logger.error('Testing error', error);
await shim.showErrorDialog(`Test failure: ${error}`); await shim.showErrorDialog(`Test failure: ${error}`);
@@ -268,10 +267,10 @@ const whisper: VoiceTypingProvider = {
} }
logger.debug('Starting whisper session', config.supportsShortAudioCtx ? '(short audio context)' : ''); logger.debug('Starting whisper session', config.supportsShortAudioCtx ? '(short audio context)' : '');
const sessionId = await SpeechToTextModule.openSession( const session = openSession({
modelPath, locale, getPrompt(locale, config.prompts), config.supportsShortAudioCtx, modelPath, locale, prompt: getPrompt(locale, config.prompts), shortAudioContext: config.supportsShortAudioCtx,
); });
return new Whisper(sessionId, callbacks, config); return new Whisper(session, callbacks, config);
}, },
modelName: 'whisper', modelName: 'whisper',
}; };
@@ -1,6 +1,6 @@
import FsDriverBase, { ReadDirStatsOptions } from '@joplin/lib/fs-driver-base'; import FsDriverBase, { ReadDirStatsOptions } from '@joplin/lib/fs-driver-base';
const RNFetchBlob = require('rn-fetch-blob').default; const RNFetchBlob = require('rn-fetch-blob').default;
import * as RNFS from 'react-native-fs'; import * as RNFS from '@dr.pogodin/react-native-fs';
import RNSAF, { DocumentFileDetail, openDocumentTree } from '@joplin/react-native-saf-x'; import RNSAF, { DocumentFileDetail, openDocumentTree } from '@joplin/react-native-saf-x';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import tarCreate from './tarCreate'; import tarCreate from './tarCreate';
@@ -310,7 +310,7 @@ export class WorkerApi {
at = writer.getSize(); at = writer.getSize();
} }
write = (data: ArrayBufferLike) => writer.write(data, { at }); write = (data: BufferSource) => writer.write(data, { at });
close = () => writer.close(); close = () => writer.close();
} catch (error) { } catch (error) {
// In some cases, createSyncAccessHandle isn't available. In other cases, // In some cases, createSyncAccessHandle isn't available. In other cases,
@@ -318,7 +318,7 @@ export class WorkerApi {
logger.warn('Failed to createSyncAccessHandle', error); logger.warn('Failed to createSyncAccessHandle', error);
const writer = await handle.createWritable({ keepExistingData: options?.keepExistingData }); const writer = await handle.createWritable({ keepExistingData: options?.keepExistingData });
write = (data: ArrayBufferLike) => writer.write(data); write = (data: BufferSource) => writer.write(data);
close = () => writer.close(); close = () => writer.close();
} }
@@ -1,10 +1,13 @@
import { Size } from '@joplin/utils/types';
import { fileUriToPath } from '@joplin/utils/url'; import { fileUriToPath } from '@joplin/utils/url';
import { Image as NativeImage, Platform } from 'react-native'; import { Image as NativeImage, Platform } from 'react-native';
import fileToImage from './fileToImage.web'; import fileToImage from './fileToImage.web';
interface ImageDimensions {
width: number;
height: number;
}
const getImageDimensions = async (uri: string): Promise<Size> => { const getImageDimensions = async (uri: string): Promise<ImageDimensions> => {
if (uri.startsWith('/')) { if (uri.startsWith('/')) {
uri = `file://${uri}`; uri = `file://${uri}`;
} }
+34 -18
View File
@@ -1,8 +1,9 @@
import shim from '@joplin/lib/shim'; import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
import ImageResizer from '@bam.tech/react-native-image-resizer'; import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
import fileToImage from './fileToImage.web'; import fileToImage from './fileToImage.web';
import FsDriverWeb from '../fs-driver/fs-driver-rn.web'; import FsDriverWeb from '../fs-driver/fs-driver-rn.web';
import getImageDimensions from './getImageDimensions';
const logger = Logger.create('resizeImage'); const logger = Logger.create('resizeImage');
@@ -18,17 +19,23 @@ interface Options {
} }
const resizeImage = async (options: Options) => { const resizeImage = async (options: Options) => {
type Sized = { width: number; height: number };
const computeScale = (image: Sized) => {
// Choose a scale factor such that the resized image fits within a
// maxWidth x maxHeight box.
const scale = Math.min(
options.maxWidth / image.width,
options.maxHeight / image.height,
);
return scale;
};
if (shim.mobilePlatform() === 'web') { if (shim.mobilePlatform() === 'web') {
const image = await fileToImage(options.inputPath); const image = await fileToImage(options.inputPath);
try { try {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
// Choose a scale factor such that the resized image fits within a const scale = computeScale(image.image);
// maxWidth x maxHeight box.
const scale = Math.min(
options.maxWidth / image.image.width,
options.maxHeight / image.image.height,
);
canvas.width = image.image.width * scale; canvas.width = image.image.width * scale;
canvas.height = image.image.height * scale; canvas.height = image.image.height * scale;
@@ -54,18 +61,27 @@ const resizeImage = async (options: Options) => {
image.free(); image.free();
} }
} else { } else {
const resizedImage = await ImageResizer.createResizedImage( const originalSize = await getImageDimensions(options.inputPath);
options.inputPath, logger.debug('Processing image with size', originalSize.width, 'x', originalSize.height);
options.maxWidth,
options.maxHeight,
options.format,
options.quality, // quality
undefined, // rotation
undefined, // outputPath
true, // keep metadata
);
const resizedImagePath = resizedImage.uri; let context = ImageManipulator.manipulate(options.inputPath);
// Only rescale the image if it's bigger than the maximum size:
if (originalSize.width > options.maxWidth || originalSize.height > options.maxHeight) {
const scale = computeScale(originalSize);
context = context.resize({
width: originalSize.width * scale,
height: originalSize.height * scale,
});
}
const final = await context.renderAsync();
const saved = await final.saveAsync({
format: options.format === 'PNG' ? SaveFormat.PNG : SaveFormat.JPEG,
compress: options.quality,
});
const resizedImagePath = saved.uri;
logger.info('Resized image ', resizedImagePath); logger.info('Resized image ', resizedImagePath);
logger.info(`Moving ${resizedImagePath} => ${options.outputPath}`); logger.info(`Moving ${resizedImagePath} => ${options.outputPath}`);
+7
View File
@@ -0,0 +1,7 @@
import shim from '@joplin/lib/shim';
// .setReact needs to be called very early in the application startup process.
// This file can be imported to ensure that .setReact has been called prior to
// all other import/requires.
const React = require('react');
shim.setReact(React);
@@ -7,7 +7,7 @@ import { generateSecureRandom } from 'react-native-securerandom';
import FsDriverRN from '../fs-driver/fs-driver-rn'; import FsDriverRN from '../fs-driver/fs-driver-rn';
import { Linking, Platform } from 'react-native'; import { Linking, Platform } from 'react-native';
import crypto from '../../services/e2ee/crypto'; import crypto from '../../services/e2ee/crypto';
const RNExitApp = require('react-native-exit-app').default; import { reloadAppAsync } from 'expo';
export default function shimInit() { export default function shimInit() {
shim.Geolocation = GeolocationReact; shim.Geolocation = GeolocationReact;
@@ -178,7 +178,7 @@ export default function shimInit() {
}; };
shim.restartApp = () => { shim.restartApp = () => {
RNExitApp.exitApp(); void reloadAppAsync();
}; };
shimInitShared(); shimInitShared();
@@ -79,6 +79,7 @@ const buildSharedConfig = (hotReload: boolean): webpack.Configuration => {
'@react-native-documents/picker': emptyLibraryMock, '@react-native-documents/picker': emptyLibraryMock,
'react-native-exit-app': emptyLibraryMock, 'react-native-exit-app': emptyLibraryMock,
'expo-camera': emptyLibraryMock, 'expo-camera': emptyLibraryMock,
'react-native-nitro-modules': emptyLibraryMock,
'react-native-vector-icons/MaterialCommunityIcons': throwOnLoadLibraryMock, 'react-native-vector-icons/MaterialCommunityIcons': throwOnLoadLibraryMock,
// Workaround for applying serviceworker types to a single file. // Workaround for applying serviceworker types to a single file.
+1 -1
View File
@@ -220,7 +220,7 @@ function shimInit(options: ShimInitOptions = null) {
if (shim.isElectron()) { if (shim.isElectron()) {
return shim.electronBridge().showMessageBox(message, options ?? {}); return shim.electronBridge().showMessageBox(message, options ?? {});
} else { } else {
throw new Error('Not implemented'); throw new Error(`Not implemented: showMessageBox(${JSON.stringify(message)})`);
} }
}; };
+2 -2
View File
@@ -46,8 +46,8 @@
"devDependencies": { "devDependencies": {
"@babel/core": "7.16.0", "@babel/core": "7.16.0",
"@types/react-native": "0.64.19", "@types/react-native": "0.64.19",
"react": "18.3.1", "react": "19.1.0",
"react-native": "0.70.6", "react-native": "0.81.5",
"typescript": "5.8.3" "typescript": "5.8.3"
}, },
"peerDependencies": { "peerDependencies": {
+1
View File
@@ -241,6 +241,7 @@ xhdpi
xxhdpi xxhdpi
xxxhdpi xxxhdpi
scrollend scrollend
pogodin
youtube youtube
youtu youtu
nocookie nocookie
+1
View File
@@ -145,6 +145,7 @@ async function main() {
await updatePackageVersion(`${rootDir}/packages/default-plugins/package.json`, majorMinorVersion, options); await updatePackageVersion(`${rootDir}/packages/default-plugins/package.json`, majorMinorVersion, options);
await updatePackageVersion(`${rootDir}/packages/editor/package.json`, majorMinorVersion, options); await updatePackageVersion(`${rootDir}/packages/editor/package.json`, majorMinorVersion, options);
await updatePackageVersion(`${rootDir}/packages/transcribe/package.json`, majorMinorVersion, options); await updatePackageVersion(`${rootDir}/packages/transcribe/package.json`, majorMinorVersion, options);
await updatePackageVersion(`${rootDir}/packages/whisper-voice-typing/package.json`, majorMinorVersion, options);
if (options.updateVersion) { if (options.updateVersion) {
await updateGradleVersion(`${rootDir}/packages/app-mobile/android/app/build.gradle`, majorMinorVersion); await updateGradleVersion(`${rootDir}/packages/app-mobile/android/app/build.gradle`, majorMinorVersion);
+84
View File
@@ -0,0 +1,84 @@
# OSX
#
.DS_Store
# XDE
.expo/
# VSCode
.vscode/
jsconfig.json
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
project.xcworkspace
# Android/IJ
#
.classpath
.cxx
.gradle
.idea
.project
.settings
local.properties
android.iml
# Cocoapods
#
example/ios/Pods
# Ruby
example/vendor/
# node.js
#
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
# BUCK
buck-out/
\.buckd/
android/app/libs
android/keystores/debug.keystore
# Yarn
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Expo
.expo/
# Turborepo
.turbo/
# generated
lib/
nitrogen/
# caches
.eslintcache
.cache
*.tsbuildinfo
@@ -0,0 +1 @@
{}
+17
View File
@@ -0,0 +1,17 @@
# react-native-whisper-voice-typing
Wraps `whisper.cpp` for use in React Native.
## Development
This package uses [react-native-nitro-modules](https://nitro.margelo.com/docs/what-is-nitro) and was scaffolded using [`nitrogen`](https://nitro.margelo.com/docs/how-to-build-a-nitro-module#1-create-a-nitro-module).
### Adding a new native hybrid object
Process summary:
1. Add a TypeScript type declaration to `src/specs/Whisper.nitro.ts`.
2. (Optional) Update `nitro.json`'s autolinking section.
3. Run `yarn build`. This regenerates the `nitrogen/generated` folder.
4. Implement the new interface(s) in `android/src/main/java/` or `cpp/`.
See [the documentation](https://nitro.margelo.com/docs/nitro-modules) for full instructions.
@@ -0,0 +1,42 @@
require "json"
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
# Currently disabled on iOS.
# To enable,
# 1. Uncomment the code after "DISABLED:"
# 2. Run vencor/whisper.cpp/build-xcframework.sh
# 3. Add vendor/whisper.cpp as a dependency?
# 4. Implement cpp/IAudioRecorder.hpp for iOS.
Pod::Spec.new do |s|
s.name = "WhisperVoiceTyping"
s.version = package["version"]
s.summary = package["description"]
s.homepage = package["homepage"]
s.license = package["license"]
s.authors = package["author"]
s.platforms = { :ios => min_ios_version_supported, :visionos => 1.0 }
s.source = { :git => "https://github.com/mrousavy/nitro.git", :tag => "#{s.version}" }
s.source_files = [
# Implementation (Swift)
"ios/**/*.{swift}",
# Autolinking/Registration (Objective-C++)
"ios/**/*.{m,mm}",
# Implementation (C++ objects)
# DISABLED: for now:
#"cpp/**/*.{hpp,cpp}",
]
# DISABLED: Currently, this package lacks iOS support
#load 'nitrogen/generated/ios/WhisperVoiceTyping+autolinking.rb'
#add_nitrogen_files(s)
s.dependency 'React-jsi'
s.dependency 'React-callinvoker'
install_modules_dependencies(s)
end
@@ -0,0 +1,56 @@
cmake_minimum_required(VERSION 3.9.0)
project(WhisperVoiceTyping)
set(PACKAGE_NAME WhisperVoiceTyping)
set(CMAKE_VERBOSE_MAKEFILE ON)
set(CMAKE_CXX_STANDARD 20)
# Define C++ library and add all sources
add_library(
${PACKAGE_NAME} SHARED
src/main/cpp/cpp-adapter.cpp
../cpp/utils/testing.cpp
../cpp/utils/findLongestSilence_test.cpp
../cpp/utils/findLongestSilence.cpp
../cpp/utils/WhisperSession.cpp
../cpp/utils/SingleThread.cpp
../cpp/utils/SingleThread_test.cpp
../cpp/HybridWhisperSession.cpp
../cpp/HybridWhisperVoiceTyping.cpp
)
# Add Nitrogen specs :)
include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/WhisperVoiceTyping+autolinking.cmake)
# Set up local includes
include_directories("src/main/cpp" "../cpp" "../cpp/utils")
################################
# Whisper.cpp configuration #
################################
set(WHISPER_LIB_DIR ${CMAKE_SOURCE_DIR}/../vendor/whisper.cpp)
# Based on the Whisper.cpp Android example:
set(SHARED_FLAGS "-O3 ")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${SHARED_FLAGS} ")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${SHARED_FLAGS} -fvisibility=hidden -fvisibility-inlines-hidden -ffunction-sections -fdata-sections")
# Whisper: See https://stackoverflow.com/a/76290722
add_subdirectory(${WHISPER_LIB_DIR} ./whisper)
include_directories(${WHISPER_LIB_DIR}/include)
#################################
# END Whisper.cpp configuration #
#################################
find_library(LOG_LIB log)
# Link all libraries together
target_link_libraries(
${PACKAGE_NAME}
${LOG_LIB}
whisper
android # <-- Android core
)
@@ -0,0 +1,124 @@
// Note: Based on part of the [react-native-builder-bob](https://github.com/callstack/react-native-builder-bob)
// Nitro module template.
buildscript {
ext.WhisperVoiceTyping = [
kotlinVersion: "2.0.21",
minSdkVersion: 24,
compileSdkVersion: 36,
targetSdkVersion: 36
]
ext.getExtOrDefault = { prop ->
if (rootProject.ext.has(prop)) {
return rootProject.ext.get(prop)
}
return WhisperVoiceTyping[prop]
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:8.7.2"
// noinspection DifferentKotlinGradleVersion
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
}
}
def reactNativeArchitectures() {
def value = rootProject.getProperties().get("reactNativeArchitectures")
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
}
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
apply from: '../nitrogen/generated/android/WhisperVoiceTyping+autolinking.gradle'
// Disabled: Including the "com.facebook.react" plugin seems to cause "ActivityIndicatorViewManagerDelegate is defined multiple times"
// errors:
// apply plugin: "com.facebook.react"
android {
namespace "com.margelo.nitro.whispervoicetyping"
compileSdkVersion getExtOrDefault("compileSdkVersion")
defaultConfig {
minSdkVersion getExtOrDefault("minSdkVersion")
targetSdkVersion getExtOrDefault("targetSdkVersion")
externalNativeBuild {
cmake {
cppFlags "-frtti -fexceptions -Wall -fstack-protector-all"
arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
abiFilters (*reactNativeArchitectures())
buildTypes {
debug {
cppFlags "-O1 -g"
}
release {
cppFlags "-O2"
}
}
}
}
}
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
packagingOptions {
excludes = [
"META-INF",
"META-INF/**",
"**/libc++_shared.so",
"**/libNitroModules.so",
"**/libfbjni.so",
"**/libjsi.so",
"**/libfolly_json.so",
"**/libfolly_runtime.so",
"**/libglog.so",
"**/libhermes.so",
"**/libhermes-executor-debug.so",
"**/libhermes_executor.so",
"**/libreactnative.so",
"**/libreactnativejni.so",
"**/libturbomodulejsijni.so",
"**/libreact_nativemodule_core.so",
"**/libjscexecutor.so"
]
}
buildFeatures {
buildConfig true
prefab true
}
buildTypes {
release {
minifyEnabled false
}
}
lint {
disable "GradleCompatible"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation "com.facebook.react:react-android"
implementation project(":react-native-nitro-modules")
}
@@ -0,0 +1,5 @@
WhisperVoiceTyping_kotlinVersion=2.1.20
WhisperVoiceTyping_minSdkVersion=23
WhisperVoiceTyping_targetSdkVersion=36
WhisperVoiceTyping_compileSdkVersion=36
WhisperVoiceTyping_ndkVersion=27.1.12297006
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,6 @@
#include <jni.h>
#include "WhisperVoiceTypingOnLoad.hpp"
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) {
return margelo::nitro::whispervoicetyping::initialize(vm);
}
@@ -1,4 +1,4 @@
package net.cozic.joplin.audio package com.margelo.nitro.whispervoicetyping
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
@@ -10,22 +10,20 @@ import android.media.MediaRecorder.AudioSource
import java.io.Closeable import java.io.Closeable
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import com.margelo.nitro.NitroModules
import java.nio.FloatBuffer
typealias AudioRecorderFactory = (context: Context)->AudioRecorder; class AudioRecorder (context: Context) : Closeable {
class AudioRecorder(context: Context) : Closeable {
private val sampleRate = 16_000 private val sampleRate = 16_000
// Don't allow the unprocessed audio buffer to grow indefinitely -- discard // Don't allow the unprocessed audio buffer to grow indefinitely -- discard
// data if longer than this: // data if longer than this:
private val maxLengthSeconds = 120 private val maxLengthSeconds = 120
private val maxRecorderBufferLengthSeconds = 20 private val maxRecorderBufferLengthSeconds = 20
private val maxBufferSize = sampleRate * maxLengthSeconds private val maxBufferSizeFloats = sampleRate * maxLengthSeconds
private val buffer = FloatArray(maxBufferSize) private val buffer = FloatArray(maxBufferSizeFloats)
private var bufferWriteOffset = 0 private var bufferWriteOffset = 0
// Accessor must not modify result
private val bufferedData: FloatArray get() = buffer.sliceArray(0 until bufferWriteOffset)
val bufferLengthSeconds: Double get() = bufferWriteOffset.toDouble() / sampleRate
init { init {
val permissionResult = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) val permissionResult = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO)
@@ -46,65 +44,61 @@ class AudioRecorder(context: Context) : Closeable {
.setChannelMask(AudioFormat.CHANNEL_IN_MONO) .setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build() .build()
) )
// Use a smaller internal buffer size in the recorder
.setBufferSizeInBytes(maxRecorderBufferLengthSeconds * sampleRate * Float.SIZE_BYTES) .setBufferSizeInBytes(maxRecorderBufferLengthSeconds * sampleRate * Float.SIZE_BYTES)
.build() .build()
// Discards the first [samples] samples from the start of the buffer. Conceptually, this // Discards the first [samples] samples from the start of the buffer. Conceptually, this
// advances the buffer's start point. // advances the buffer's start point.
private fun advanceStartBySamples(samples: Int) { private fun advanceStartBySamples(samples: Int) {
val samplesClamped = min(samples, maxBufferSize) val samplesClamped = min(samples, maxBufferSizeFloats)
val remainingBuffer = buffer.sliceArray(samplesClamped until maxBufferSize) val remainingBuffer = buffer.sliceArray(samplesClamped until maxBufferSizeFloats)
buffer.fill(0f, samplesClamped, maxBufferSize) buffer.fill(0f, samplesClamped, maxBufferSizeFloats)
remainingBuffer.copyInto(buffer, 0) remainingBuffer.copyInto(buffer, 0)
bufferWriteOffset = max(bufferWriteOffset - samplesClamped, 0) bufferWriteOffset = max(bufferWriteOffset - samplesClamped, 0)
} }
fun dropFirstSeconds(seconds: Double) {
advanceStartBySamples((seconds * sampleRate).toInt())
}
fun start() { fun start() {
recorder.startRecording() recorder.startRecording()
} }
@Synchronized
private fun read(requestedSize: Int, mode: Int) { private fun read(requestedSize: Int, mode: Int) {
val size = min(requestedSize, maxBufferSize - bufferWriteOffset) val size = min(requestedSize, maxBufferSizeFloats - bufferWriteOffset)
val sizeRead = recorder.read(buffer, bufferWriteOffset, size, mode) val sizeRead = recorder.read(buffer, bufferWriteOffset, size, mode)
if (sizeRead > 0) { if (sizeRead > 0) {
bufferWriteOffset += sizeRead bufferWriteOffset += sizeRead
} }
} }
// Pulls all available data from the audio recorder's buffer // Returns a pointer to the buffered data
fun pullAvailable(): FloatArray { fun pullAvailable(): FloatArray {
read(maxBufferSize, AudioRecord.READ_NON_BLOCKING) read(maxBufferSizeFloats, AudioRecord.READ_NON_BLOCKING)
return buffer.sliceArray(0 until bufferWriteOffset)
val result = bufferedData
buffer.fill(0.0f, 0, maxBufferSize);
bufferWriteOffset = 0
return result
} }
fun pullNextSeconds(seconds: Double):FloatArray { // Sets the buffer write offset back to zero.
val remainingSize = maxBufferSize - bufferWriteOffset fun resetBuffer() {
bufferWriteOffset = 0
}
// Pushes at least [seconds] seconds of new data to the recording buffer.
// Call "pullAvailable" to read the buffered data.
fun bufferAdditionalData(seconds: Double) {
val remainingSize = maxBufferSizeFloats - bufferWriteOffset
val requestedSize = (seconds * sampleRate).toInt() val requestedSize = (seconds * sampleRate).toInt()
// If low on size, make more room. // If low on size, make more room.
if (remainingSize < maxBufferSize / 3) { if (remainingSize < maxBufferSizeFloats / 3) {
advanceStartBySamples(maxBufferSize / 3) advanceStartBySamples(maxBufferSizeFloats / 3)
} }
read(requestedSize, AudioRecord.READ_BLOCKING) read(requestedSize, AudioRecord.READ_BLOCKING)
return pullAvailable()
} }
override fun close() { override fun close() {
recorder.stop() recorder.stop()
recorder.release() recorder.release()
} }
}
companion object {
val factory: AudioRecorderFactory = { context -> AudioRecorder(context) }
}
}
@@ -0,0 +1,44 @@
package com.margelo.nitro.whispervoicetyping
import android.util.Log
import com.margelo.nitro.core.ArrayBuffer
import com.margelo.nitro.core.Promise
import com.margelo.nitro.NitroModules
import java.nio.ByteBuffer
import java.nio.ByteOrder
// Wraps an AudioRecorder in an interface that can be accessed from C++
class HybridAudioRecorder : HybridAudioRecorderSpec() {
private val recorder = AudioRecorder(NitroModules.applicationContext!!)
override fun start() {
recorder.start()
}
override fun stop() {
recorder.close()
}
override fun waitForData(seconds: Double): Promise<Unit> {
return Promise.async {
recorder.bufferAdditionalData(seconds)
}
}
override fun pullAvailable(): ArrayBuffer {
val floatData = recorder.pullAvailable()
val bytes = convertToBytes(floatData)
val buffer = ArrayBuffer.copy(bytes)
recorder.resetBuffer()
return buffer
}
private fun convertToBytes(floatData: FloatArray): ByteArray {
// Convert to a ByteBuffer first. (Similar approach to https://stackoverflow.com/q/11385596)
val output = ByteBuffer.allocate(floatData.size * Float.SIZE_BYTES)
output.order(ByteOrder.nativeOrder())
output.asFloatBuffer().put(floatData)
return output.array()
}
}
@@ -0,0 +1,22 @@
package com.margelo.nitro.whispervoicetyping
import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfoProvider
class WhisperVoiceTypingPackage : BaseReactPackage() {
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
return null
}
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
return ReactModuleInfoProvider { HashMap() }
}
companion object {
init {
System.loadLibrary("WhisperVoiceTyping")
}
}
}
@@ -0,0 +1,3 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
};
@@ -0,0 +1,48 @@
#include "HybridWhisperSession.hpp"
#include "androidUtil.h"
#include <NitroModules/ArrayBuffer.hpp>
using namespace margelo::nitro::whispervoicetyping;
struct HybridWhisperSession::State_ {
State_(const SessionOptions& options)
: session_(options.modelPath, options.locale, options.prompt, options.shortAudioContext)
{};
void addAudio(ArrayBuffer& data);
std::mutex mutex_;
WhisperSession session_;
};
void HybridWhisperSession::State_::addAudio(ArrayBuffer& data) {
float* dataFloat = reinterpret_cast<float*>(data.data());
size_t sizeFloats = data.size() / sizeof(float);
if (dataFloat == nullptr) {
throw std::logic_error("Attempting to add audio from a source that has already been deleted.");
}
LOGD("Add audio (size %zu)", sizeFloats);
session_.addAudio(dataFloat, sizeFloats);
}
HybridWhisperSession::HybridWhisperSession(
const SessionOptions& options
)
: HybridObject(TAG),
state_(std::make_shared<HybridWhisperSession::State_>(options))
{ }
void HybridWhisperSession::pushAudio(const std::shared_ptr<ArrayBuffer>& audio) {
std::lock_guard<std::mutex> lock { state_->mutex_ };
state_->addAudio(*audio);
}
std::shared_ptr<Promise<std::string>> HybridWhisperSession::convertNext() {
return Promise<std::string>::async([state = state_] () -> std::string {
std::lock_guard<std::mutex> lock { state->mutex_ };
return state->session_.transcribeNextChunk();
});
}
@@ -0,0 +1,22 @@
#pragma once
#include <mutex>
#include <memory>
#include "HybridWhisperSessionSpec.hpp"
#include "SessionOptions.hpp"
#include "utils/WhisperSession.hpp"
namespace margelo::nitro::whispervoicetyping {
class HybridWhisperSession : public HybridWhisperSessionSpec {
public:
explicit HybridWhisperSession(const SessionOptions& options);
void pushAudio(const std::shared_ptr<ArrayBuffer>& audio) override;
std::shared_ptr<Promise<std::string>> convertNext() override;
private:
struct State_;
std::shared_ptr<State_> state_;
};
}
@@ -0,0 +1,20 @@
#include <memory>
#include "HybridWhisperVoiceTyping.hpp"
#include "HybridWhisperSession.hpp"
#include "findLongestSilence_test.hpp"
#include "SingleThread_test.hpp"
using namespace margelo::nitro::whispervoicetyping;
SessionPointer HybridWhisperVoiceTyping::openSession(const SessionOptions& options) {
return std::make_shared<HybridWhisperSession> (options);
}
std::shared_ptr<Promise<void>> HybridWhisperVoiceTyping::test() {
return Promise<void>::async([=] () -> void {
findLongestSilence_test();
SingleThread_test();
});
}
@@ -0,0 +1,16 @@
#pragma once
#include "HybridWhisperVoiceTypingSpec.hpp"
namespace margelo::nitro::whispervoicetyping {
using SessionPointer = std::shared_ptr<HybridWhisperSessionSpec>;
class HybridWhisperVoiceTyping : public HybridWhisperVoiceTypingSpec {
public:
HybridWhisperVoiceTyping(): HybridObject(TAG) {};
SessionPointer openSession(const SessionOptions& options) override;
// Runs tests
std::shared_ptr<Promise<void>> test() override;
};
}
@@ -0,0 +1,59 @@
#include "SingleThread.hpp"
#include "androidUtil.h"
#include <condition_variable>
#include <thread>
#include <mutex>
struct SingleThread::State_ {
std::vector<std::function<void()>> tasks;
// mutex_ and cv_ are used as a signaling mechanism.
// See, for example https://cplusplus.com/reference/condition_variable/condition_variable/
// https://en.cppreference.com/w/cpp/thread/condition_variable.html
std::condition_variable cv;
std::mutex mutex;
bool cancelled = false;
};
void threadLoop(std::shared_ptr<SingleThread::State_> state) {
while (!state->cancelled) {
// Create a lock that can be used by state->cv (see https://stackoverflow.com/a/13102893
// for why).
std::unique_lock lock {state->mutex};
// Run all tasks **first**, to handle the case where the threadLoop starts after the first
// task has been posted.
for (auto& task : state->tasks) {
task();
}
state->tasks.clear();
state->cv.wait(lock);
}
}
SingleThread::SingleThread()
: state_ { std::make_shared<State_>() },
thread_ { threadLoop, state_ } {
}
SingleThread::~SingleThread() {
{
std::lock_guard lock { state_->mutex };
state_->cancelled = true;
}
state_->cv.notify_all();
thread_.join();
}
void SingleThread::post(std::function<void()> task) {
{
std::lock_guard lock { state_->mutex };
state_->tasks.push_back(std::move(task));
}
state_->cv.notify_all();
}
@@ -0,0 +1,61 @@
#pragma once
#include <thread>
#include <functional>
#include <future>
// Reserves a single thread. Similar to Java/Android's "Handler"
class SingleThread {
public:
SingleThread();
// Waits for all pending tasks to complete before destruction
~SingleThread();
// Run the `task` on the thread and return a future that will resolve to the result
template <typename T>
std::future<T> run(std::function<T()> task) {
auto promise = std::make_shared<std::promise<T>>();
post([promise = promise, task = std::move(task)] {
try {
T result = task();
promise->set_value(result);
} catch (...) {
// Use current_exception to create an exception pointer.
// See https://en.cppreference.com/w/cpp/thread/promise/set_exception
promise->set_exception(std::current_exception());
}
});
return promise->get_future();
}
// Explicit definition of run<void> to work around templating issues:
std::future<void> run(std::function<void()> task) {
auto promise = std::make_shared<std::promise<void>>();
post([promise = promise, task = std::move(task)] {
try {
task();
promise->set_value();
} catch (...) {
promise->set_exception(std::current_exception());
}
});
return promise->get_future();
}
// Run the `task` on the thread and wait for it to complete
template <typename T>
T runAndWait(std::function<T()> task) {
return run(task).get();
}
struct State_;
private:
// Asynchronously runs the given `task` on the thread
void post(std::function<void()> task);
private:
std::shared_ptr<State_> state_;
std::thread thread_;
};
@@ -0,0 +1,82 @@
#include "SingleThread_test.hpp"
#include "SingleThread.hpp"
#include "testing.hpp"
#include "androidUtil.h"
#include <string>
#include <vector>
#include <sstream>
#include <cmath>
#include <random>
void shouldConstructAndDestruct();
void shouldReturnSingleValue();
void shouldReturnMultipleAsync();
void shouldForwardExceptions();
void SingleThread_test() {
LOGD("SingleThread: Starting tests...");
shouldConstructAndDestruct();
shouldReturnSingleValue();
shouldReturnMultipleAsync();
shouldForwardExceptions();
}
void shouldConstructAndDestruct() {
{
SingleThread thread {};
LOGD("SingleThread: Constructed!");
}
LOGD("SingleThread: Destructed!");
logTestPass("shouldConstructAndDestruct");
}
void shouldReturnSingleValue() {
SingleThread thread {};
LOGD("SingleThread: Posting a value and waiting!");
assertEqual(thread.runAndWait<int>([] {
LOGD("SingleThread: Inside thread!");
return 3;
}), 3, "should support returning a value from a lambda");
LOGD("SingleThread: Posted a value and waited!");
logTestPass("shouldReturnSingleValue");
}
void shouldReturnMultipleAsync() {
SingleThread thread {};
auto future1 = thread.run<int>([] {
return 1;
});
auto future2 = thread.run<int>([] {
return 2;
});
auto future3 = thread.run<int>([] {
return 3;
});
assertEqual(future1.get(), 1, "getting future1");
assertEqual(future3.get(), 3, "getting future3");
assertEqual(future2.get(), 2, "getting future2");
logTestPass("shouldReturnMultipleAsync");
}
void shouldForwardExceptions() {
SingleThread thread {};
bool caught = false;
try {
thread.runAndWait<void>([] {
throw std::runtime_error("testing...");
});
} catch (std::runtime_error ex) {
caught = true;
}
assertEqual(caught, true, "should have caught the exception");
logTestPass("shouldForwardExceptions");
}
@@ -0,0 +1,3 @@
#pragma once
void SingleThread_test();
@@ -1,10 +1,10 @@
#include "WhisperSession.h" #include "WhisperSession.hpp"
#include <utility> #include <utility>
#include <sstream> #include <sstream>
#include <algorithm> #include <algorithm>
#include "whisper.h" #include "whisper.h"
#include "findLongestSilence.h" #include "findLongestSilence.hpp"
#include "androidUtil.h" #include "androidUtil.h"
WhisperSession::WhisperSession(const std::string& modelPath, std::string lang, std::string prompt, bool shortAudioContext) WhisperSession::WhisperSession(const std::string& modelPath, std::string lang, std::string prompt, bool shortAudioContext)
@@ -216,11 +216,11 @@ WhisperSession::transcribeNextChunk() {
} }
void WhisperSession::addAudio(const float *pAudio, int sizeAudio) { void WhisperSession::addAudio(float* data, size_t count) {
// Update the local audio buffer // See https://en.cppreference.com/w/cpp/algorithm/copy_n.html,
for (int i = 0; i < sizeAudio; i++) { // and the suggestions at https://stackoverflow.com/q/34552783.
audioBuffer_.push_back(pAudio[i]); audioBuffer_.reserve(count);
} std::copy_n(data, count, std::back_inserter(audioBuffer_));
} }
std::string WhisperSession::transcribeAll() { std::string WhisperSession::transcribeAll() {
@@ -10,7 +10,7 @@ public:
WhisperSession(const std::string& modelPath, std::string lang, std::string prompt, bool shortAudioContext); WhisperSession(const std::string& modelPath, std::string lang, std::string prompt, bool shortAudioContext);
~WhisperSession(); ~WhisperSession();
// Adds to the buffer // Adds to the buffer
void addAudio(const float *pAudio, int sizeAudio); void addAudio(float* audio, size_t count);
// Returns the next finalized slice of audio (if any) and updates the preview. // Returns the next finalized slice of audio (if any) and updates the preview.
std::string transcribeNextChunk(); std::string transcribeNextChunk();
// Transcribes all buffered audio data that hasn't been finalized yet // Transcribes all buffered audio data that hasn't been finalized yet
@@ -1,5 +1,15 @@
#pragma once #pragma once
#ifdef __APPLE__
// For now, logging methods are no-ops on iOS
#define LOGW(...) void;
#define LOGI(...) void;
#define LOGD(...) void;
#else
#include <android/log.h> #include <android/log.h>
// Use macros for these rather than functions. Functions generate a "may be unsafe" // Use macros for these rather than functions. Functions generate a "may be unsafe"
@@ -8,3 +18,5 @@
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, "Whisper::JNI", __VA_ARGS__); #define LOGW(...) __android_log_print(ANDROID_LOG_WARN, "Whisper::JNI", __VA_ARGS__);
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "Whisper::JNI", __VA_ARGS__); #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "Whisper::JNI", __VA_ARGS__);
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "Whisper::JNI", __VA_ARGS__); #define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "Whisper::JNI", __VA_ARGS__);
#endif
@@ -1,4 +1,4 @@
#include "findLongestSilence.h" #include "findLongestSilence.hpp"
#include "androidUtil.h" #include "androidUtil.h"
static void highpass(std::vector<float>& data, int sampleRate) { static void highpass(std::vector<float>& data, int sampleRate) {
@@ -1,6 +1,7 @@
#include "findLongestSilence_test.h" #include "findLongestSilence_test.hpp"
#include "findLongestSilence.h" #include "findLongestSilence.hpp"
#include "androidUtil.h" #include "androidUtil.h"
#include "testing.hpp"
#include <string> #include <string>
#include <vector> #include <vector>
@@ -13,8 +14,6 @@ static void testToneWithPause();
static void testSilence(); static void testSilence();
static void testNoise(); static void testNoise();
static void fail(const std::string& message);
struct GeneratedAudio { struct GeneratedAudio {
std::vector<float> data; std::vector<float> data;
int sampleRate; int sampleRate;
@@ -91,10 +90,6 @@ static void testNoise() {
} }
static void fail(const std::string& message) {
throw std::runtime_error(message);
}
static GeneratedAudio makeAudio(const AudioGenerator& generator, int sampleRate, float duration) { static GeneratedAudio makeAudio(const AudioGenerator& generator, int sampleRate, float duration) {
std::vector<float> result { }; std::vector<float> result { };
@@ -111,9 +106,6 @@ static GeneratedAudio makeAudio(const AudioGenerator& generator, int sampleRate,
}; };
} }
static void logTestPass(const std::string& message) {
LOGI("Test PASS: %s", message.c_str());
}
static float samplesToSeconds(int samples, int sampleRate) { static float samplesToSeconds(int samples, int sampleRate) {
return static_cast<float>(samples) / static_cast<float>(sampleRate); return static_cast<float>(samples) / static_cast<float>(sampleRate);
@@ -0,0 +1,9 @@
#include "testing.hpp"
void fail(const std::string& message) {
throw std::runtime_error(message);
}
void logTestPass(const std::string& message) {
LOGI("Test PASS: %s", message.c_str());
}
@@ -0,0 +1,20 @@
#pragma once
#include <string>
#include <sstream>
#include "androidUtil.h"
// Test-related utilities
void fail(const std::string& message);
void logTestPass(const std::string& message);
template<typename T>
void assertEqual(const T& a, const T& b, const std::string& message) {
if (a != b) {
std::stringstream fullMessage;
fullMessage << "Not equal:" << a << " != " << b << " (" << message << ")";
fail(fullMessage.str());
}
}
@@ -0,0 +1,2 @@
// Auto-generated
#pragma once
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://nitro.margelo.com/nitro.schema.json",
"cxxNamespace": [
"whispervoicetyping"
],
"ios": {
"iosModuleName": "WhisperVoiceTyping"
},
"android": {
"androidNamespace": [
"whispervoicetyping"
],
"androidCxxLibName": "WhisperVoiceTyping"
},
"autolinking": {
"WhisperVoiceTyping": {
"cpp": "HybridWhisperVoiceTyping"
},
"AudioRecorder": {
"kotlin": "HybridAudioRecorder"
}
},
"ignorePaths": [
"**/node_modules"
]
}
@@ -0,0 +1,60 @@
{
"name": "@joplin/whisper-voice-typing",
"version": "3.6.0",
"private": true,
"description": "Whisper.cpp bindings for React Native!",
"author": "Joplin team",
"main": "lib/index",
"module": "lib/index",
"types": "lib/index.d.ts",
"react-native": "src/index",
"source": "src/index",
"files": [
"src",
"react-native.config.js",
"lib",
"nitrogen",
"android/build.gradle",
"android/gradle.properties",
"android/CMakeLists.txt",
"android/src",
"ios/**/*.h",
"ios/**/*.m",
"ios/**/*.mm",
"ios/**/*.cpp",
"ios/**/*.swift",
"cppxx/**/*",
"app.plugin.js",
"nitro.json",
"*.podspec",
"README.md"
],
"scripts": {
"clean": "rm -rf android/build node_modules/**/android/build lib",
"tsc": "tsc",
"build": "tsc && nitrogen"
},
"keywords": [
"react-native",
"nitro"
],
"repository": {
"type": "git",
"url": "git+https://github.com/laurent22/joplin.git"
},
"homepage": "https://github.com/laurent22/joplin",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@types/react": "19.2.10",
"nitrogen": "0.33.2",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-nitro-modules": "0.33.2",
"typescript": "5.8.3"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-nitro-modules": "*"
}
}
@@ -0,0 +1,12 @@
// https://github.com/react-native-community/cli/blob/main/docs/dependencies.md
module.exports = {
dependency: {
platforms: {
// * @type {import('@react-native-community/cli-types').IOSDependencyParams}
ios: {},
// * @type {import('@react-native-community/cli-types').AndroidDependencyParams}
android: {},
},
},
};
@@ -0,0 +1,72 @@
import { NitroModules } from 'react-native-nitro-modules';
import type { AudioRecorder, SessionOptions, WhisperSession, WhisperVoiceTyping } from './specs/Whisper.nitro';
let WhisperVoiceTypingHybridObject: WhisperVoiceTyping|null = null;
export type { SessionOptions };
const getVoiceTyping = () => {
WhisperVoiceTypingHybridObject ??= NitroModules.createHybridObject<WhisperVoiceTyping>('WhisperVoiceTyping');
return WhisperVoiceTypingHybridObject;
};
export class Session {
private recorder_: AudioRecorder|null = null;
private nativeSession_: WhisperSession|null = null;
private convertCancellationCounter_ = 0;
public constructor(private options_: SessionOptions) {}
private isOpen_() {
return this.recorder_ || this.nativeSession_;
}
public open() {
if (this.isOpen_()) {
this.close();
}
this.recorder_ = NitroModules.createHybridObject<AudioRecorder>('AudioRecorder');
this.nativeSession_ = getVoiceTyping().openSession(this.options_);
this.recorder_.start();
}
public async convertNext(duration: number|null) {
if (!this.nativeSession_ || !this.recorder_) {
throw new Error('No available session! Call .open() before .convertNext().');
}
this.convertCancellationCounter_++;
const cancelCounter = this.convertCancellationCounter_;
if (duration) {
await this.recorder_.waitForData(duration);
}
// Cancelled?
if (cancelCounter !== this.convertCancellationCounter_) {
return '';
}
const available = this.recorder_.pullAvailable();
this.nativeSession_.pushAudio(available);
return this.nativeSession_.convertNext();
}
public close() {
this.recorder_?.stop();
// Allow recorder_ and nativeSession_ to be garbage collected:
this.recorder_ = null;
this.nativeSession_ = null;
this.convertCancellationCounter_++;
}
}
export function openSession(options: SessionOptions) {
return new Session(options);
}
export function test() {
return getVoiceTyping().test();
}
@@ -0,0 +1,25 @@
import type { HybridObject } from 'react-native-nitro-modules';
export interface AudioRecorder extends HybridObject<{ android: 'kotlin' }> {
start(): void;
stop(): void;
waitForData(seconds: number): Promise<void>;
pullAvailable(): ArrayBuffer;
}
export interface WhisperSession extends HybridObject<{ android: 'c++' }> {
pushAudio(audio: ArrayBuffer): void;
convertNext(): Promise<string>;
}
export interface SessionOptions {
modelPath: string;
locale: string;
prompt: string;
shortAudioContext: boolean;
}
export interface WhisperVoiceTyping extends HybridObject<{ android: 'c++' }> {
openSession(options: SessionOptions): WhisperSession;
test(): Promise<void>;
}
@@ -0,0 +1,31 @@
{
"include": [
"src"
],
"compilerOptions": {
"composite": true,
"outDir": "lib",
"rootDir": "src",
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react",
"lib": ["esnext"],
"module": "esnext",
"moduleResolution": "node",
"noEmit": false,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitUseStrict": false,
"noStrictGenericChecks": false,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "esnext",
"verbatimModuleSyntax": true
}
}

Some files were not shown because too many files have changed in this diff Show More