mirror of
https://github.com/laurent22/joplin.git
synced 2026-05-07 20:02:45 +00:00
Mobile: Upgrade to React Native 0.81 (#14232)
This commit is contained in:
@@ -92,6 +92,7 @@ readme/
|
||||
packages/react-native-vosk/lib/
|
||||
packages/lib/countable/Countable.js
|
||||
packages/onenote-converter/renderer/pkg/*
|
||||
packages/whisper-voice-typing/lib/
|
||||
|
||||
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
|
||||
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/Icon.js
|
||||
packages/app-mobile/components/IconButton.js
|
||||
packages/app-mobile/components/KeyboardAvoidingView.js
|
||||
packages/app-mobile/components/Modal.js
|
||||
packages/app-mobile/components/ModalDialog.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/getImageDimensions.js
|
||||
packages/app-mobile/utils/image/resizeImage.js
|
||||
packages/app-mobile/utils/initReact.js
|
||||
packages/app-mobile/utils/initializeCommandService.js
|
||||
packages/app-mobile/utils/ipc/RNToWebViewMessenger.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/render.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
|
||||
|
||||
@@ -665,6 +665,7 @@ packages/app-mobile/components/FeedbackBanner.js
|
||||
packages/app-mobile/components/FolderPicker.js
|
||||
packages/app-mobile/components/Icon.js
|
||||
packages/app-mobile/components/IconButton.js
|
||||
packages/app-mobile/components/KeyboardAvoidingView.js
|
||||
packages/app-mobile/components/Modal.js
|
||||
packages/app-mobile/components/ModalDialog.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/getImageDimensions.js
|
||||
packages/app-mobile/utils/image/resizeImage.js
|
||||
packages/app-mobile/utils/initReact.js
|
||||
packages/app-mobile/utils/initializeCommandService.js
|
||||
packages/app-mobile/utils/ipc/RNToWebViewMessenger.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/render.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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"exceptions": [
|
||||
"@joplin/editor",
|
||||
"@joplin/fork-htmlparser2",
|
||||
"@joplin/whisper-voice-typing",
|
||||
"@joplin/fork-sax",
|
||||
"@joplin/fork-uslug",
|
||||
"@joplin/htmlpack",
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
"/packages/app-desktop/build/",
|
||||
"/packages/app-desktop/utils/checkForUpdatesUtilsTestData.ts",
|
||||
"/packages/app-desktop/vendor/",
|
||||
"/packages/app-mobile/android/vendor/",
|
||||
"/packages/whisper-voice-typing/vendor/",
|
||||
"/packages/app-mobile/ios/Pods/",
|
||||
"/packages/app-mobile/lib/rnInjectedJs",
|
||||
"/packages/app-mobile/pluginAssets",
|
||||
|
||||
@@ -2,11 +2,24 @@ apply plugin: "com.android.application"
|
||||
apply plugin: "org.jetbrains.kotlin.android"
|
||||
apply plugin: "com.facebook.react"
|
||||
|
||||
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 */
|
||||
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||
// 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
|
||||
|
||||
/**
|
||||
* 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.+'
|
||||
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||
|
||||
android {
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path file('src/main/cpp/CMakeLists.txt')
|
||||
version '3.22.1'
|
||||
}
|
||||
}
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
@@ -91,19 +85,15 @@ android {
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097788
|
||||
versionName "3.6.0"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
// Needed to fix: The number of method references in a .dex file cannot exceed 64K
|
||||
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 {
|
||||
debug {
|
||||
@@ -129,7 +119,7 @@ android {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.release
|
||||
minifyEnabled enableProguardInReleaseBuilds
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
}
|
||||
profileable {
|
||||
@@ -149,10 +139,5 @@ android {
|
||||
dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
}
|
||||
|
||||
@@ -8,3 +8,7 @@
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# 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:resizeableActivity="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- 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 com.facebook.react.PackageList
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.ReactNativeHost
|
||||
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.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.share.SharePackage
|
||||
import net.cozic.joplin.ssl.SslPackage
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(this, object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
|
||||
this,
|
||||
object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
add(SharePackage())
|
||||
add(SslPackage())
|
||||
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 isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
|
||||
})
|
||||
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
||||
})
|
||||
|
||||
override val reactHost: ReactHost
|
||||
get() = ReactNativeHostWrapper.createReactHost(this.applicationContext, reactNativeHost)
|
||||
@@ -44,16 +43,17 @@ class MainApplication : Application(), ReactApplication {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
SoLoader.init(this, OpenSourceMergedSoMapping)
|
||||
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
||||
// If you opted-in for the New Architecture, we load the native entry point for this app.
|
||||
load()
|
||||
try {
|
||||
DefaultNewArchitectureEntryPoint.releaseLevel = ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
DefaultNewArchitectureEntryPoint.releaseLevel = ReleaseLevel.STABLE
|
||||
}
|
||||
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||
}
|
||||
loadReactNative(this)
|
||||
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
|
||||
}
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
|
||||
}
|
||||
}
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package net.cozic.joplin.audio
|
||||
|
||||
|
||||
class InvalidSessionIdException(id: Int) : IllegalArgumentException("Invalid session ID $id") {
|
||||
}
|
||||
-64
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
-62
@@ -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()
|
||||
}
|
||||
}
|
||||
-87
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-111
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
buildscript {
|
||||
ext {
|
||||
buildToolsVersion = "35.0.0"
|
||||
buildToolsVersion = "36.0.0"
|
||||
minSdkVersion = 24
|
||||
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
|
||||
ndkVersion = "27.1.12297006"
|
||||
kotlinVersion = "2.0.21"
|
||||
kotlinVersion = "2.1.20"
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
|
||||
@@ -16,7 +16,7 @@ org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryEr
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# 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
|
||||
# org.gradle.parallel=true
|
||||
org.gradle.parallel=true
|
||||
|
||||
# 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
|
||||
@@ -34,12 +34,17 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
# your application. You should enable this flag either if you want
|
||||
# to write custom TurboModules/Fabric components OR use libraries that
|
||||
# are providing them.
|
||||
newArchEnabled=false
|
||||
newArchEnabled=true
|
||||
|
||||
# Use this property to enable or disable the Hermes JS engine.
|
||||
# If set to false, you will be using JSC instead.
|
||||
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:
|
||||
#
|
||||
# > Failed to transform bcprov-jdk15on-1.68.jar
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Vendored
+2
-2
@@ -114,7 +114,7 @@ case "$( uname )" in #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
|
||||
+2
-2
@@ -70,11 +70,11 @@ goto fail
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@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
|
||||
@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
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"name": "Joplin",
|
||||
"displayName": "Joplin"
|
||||
}
|
||||
"displayName": "Joplin",
|
||||
"plugins": [
|
||||
"@react-native-community/datetimepicker"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ const useStyles = (theme: ThemeStyle) => {
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 20,
|
||||
paddingBottom: 14,
|
||||
paddingBottom: 14 + safeAreaPadding.paddingBottom,
|
||||
gap: 8,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
|
||||
@@ -13,6 +13,7 @@ import shim from '@joplin/lib/shim';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { Props, WebViewControl } from './types';
|
||||
import useCss from './utils/useCss';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const logger = Logger.create('ExtendedWebView');
|
||||
|
||||
@@ -141,7 +142,8 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
onLoadEnd={props.onLoadEnd}
|
||||
onContentProcessDidTerminate={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;
|
||||
@@ -1,12 +1,13 @@
|
||||
import * as React 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 { msleep, Second } from '@joplin/utils/time';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { ModalState } from './accessibility/FocusControl/types';
|
||||
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import KeyboardAvoidingView from './KeyboardAvoidingView';
|
||||
|
||||
export interface ModalElementProps extends ModalProps {
|
||||
children: React.ReactNode;
|
||||
@@ -175,7 +176,7 @@ const ModalElement: React.FC<ModalElementProps> = ({
|
||||
{...modalProps}
|
||||
>
|
||||
{scrollOverflow ? (
|
||||
<KeyboardAvoidingView behavior='padding' style={styles.keyboardAvoidingView}>
|
||||
<KeyboardAvoidingView style={styles.keyboardAvoidingView} enabled={true}>
|
||||
<ScrollView
|
||||
{...extraScrollViewProps}
|
||||
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 (confirm(switchProfileMessage)) {
|
||||
void doIt();
|
||||
|
||||
@@ -688,8 +688,8 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
const menuComp =
|
||||
!menuOptions.length || !showContextMenuButton ? null : (
|
||||
<Menu themeId={this.props.themeId} options={menuOptions}>
|
||||
<View style={contextMenuStyle} accessibilityLabel={_('Actions')}>
|
||||
<Icon name="ionicon ellipsis-vertical" style={this.styles().contextMenuTrigger} accessibilityLabel={null}/>
|
||||
<View style={contextMenuStyle}>
|
||||
<Icon name="ionicon ellipsis-vertical" style={this.styles().contextMenuTrigger} accessibilityLabel={_('Actions')}/>
|
||||
</View>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import AccessibleView from './accessibility/AccessibleView';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useReduceMotionEnabled from '../utils/hooks/useReduceMotionEnabled';
|
||||
import { themeStyle } from './global-style';
|
||||
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
|
||||
|
||||
export enum SideMenuPosition {
|
||||
Left = 'left',
|
||||
@@ -40,6 +41,8 @@ interface UseStylesProps {
|
||||
|
||||
const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStylesProps) => {
|
||||
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
|
||||
const safeAreaInsets = useSafeAreaPadding();
|
||||
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return StyleSheet.create({
|
||||
@@ -53,7 +56,7 @@ const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStyl
|
||||
contentOuterWrapper: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
width: windowWidth,
|
||||
width: '100%',
|
||||
height: windowHeight,
|
||||
transform: [{
|
||||
translateX: menuOpenFraction.interpolate({
|
||||
@@ -71,11 +74,18 @@ const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStyl
|
||||
flexShrink: 1,
|
||||
},
|
||||
menuWrapper: {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
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`.
|
||||
// As such, we need to reverse the normal direction in RTL mode.
|
||||
...(isLeftMenu === !I18nManager.isRTL ? {
|
||||
@@ -107,7 +117,7 @@ const useStyles = ({ themeId, isLeftMenu, menuWidth, menuOpenFraction }: UseStyl
|
||||
width: windowWidth,
|
||||
},
|
||||
});
|
||||
}, [themeId, isLeftMenu, windowWidth, windowHeight, menuWidth, menuOpenFraction]);
|
||||
}, [themeId, isLeftMenu, windowWidth, windowHeight, menuWidth, menuOpenFraction, safeAreaInsets]);
|
||||
};
|
||||
|
||||
interface UseAnimationsProps {
|
||||
|
||||
@@ -2,13 +2,16 @@ import * as React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import NotesScreen from './screens/Notes/Notes';
|
||||
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 { themeStyle } from './global-style';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import useKeyboardState from '../utils/hooks/useKeyboardState';
|
||||
import usePrevious from '@joplin/lib/hooks/usePrevious';
|
||||
import FeedbackBanner from './FeedbackBanner';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { useMemo } from 'react';
|
||||
import KeyboardAvoidingView from './KeyboardAvoidingView';
|
||||
|
||||
interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -20,6 +23,15 @@ interface Props {
|
||||
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 keyboardState = useKeyboardState();
|
||||
const safeAreaPadding = useSafeAreaInsets();
|
||||
@@ -50,20 +62,18 @@ const AppNavComponent: React.FC<Props> = (props) => {
|
||||
const searchScreenLoaded = searchScreenVisible || (previousRouteName === 'Search' && route.routeName === 'Note');
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const style = { flex: 1, backgroundColor: theme.backgroundColor };
|
||||
|
||||
// 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;
|
||||
const styles = useStyles(theme);
|
||||
const autocompletionBarPadding = keyboardState.keyboardVisible ? safeAreaPadding.top : 0;
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
enabled={keyboardAvoidingViewEnabled}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : null}
|
||||
style={style}
|
||||
style={styles.keyboardAvoidingView}
|
||||
enabled={
|
||||
// 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} />
|
||||
{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />}
|
||||
|
||||
@@ -16,6 +16,7 @@ import usePrevious from '@joplin/lib/hooks/usePrevious';
|
||||
import PlatformImplementation from '../../services/plugins/PlatformImplementation';
|
||||
import AccessibleView from '../accessibility/AccessibleView';
|
||||
import useOnDevPluginsUpdated from './utils/useOnDevPluginsUpdated';
|
||||
import { ViewStyle } from 'react-native';
|
||||
|
||||
const logger = Logger.create('PluginRunnerWebView');
|
||||
|
||||
@@ -98,6 +99,17 @@ interface Props {
|
||||
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 webviewRef = useRef<WebViewControl>(null);
|
||||
|
||||
@@ -189,7 +201,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
|
||||
};
|
||||
|
||||
return (
|
||||
<AccessibleView style={{ display: 'none' }} inert={true}>
|
||||
<AccessibleView style={hiddenStyle} inert={true}>
|
||||
{renderWebView()}
|
||||
</AccessibleView>
|
||||
);
|
||||
|
||||
@@ -6,16 +6,14 @@
|
||||
|
||||
// So there's basically still a one way flux: React => SQLite => Redux => React
|
||||
|
||||
import './utils/initReact';
|
||||
import './utils/polyfills';
|
||||
|
||||
import Root from './root';
|
||||
import { LogBox } from 'react-native';
|
||||
import { registerRootComponent } from 'expo';
|
||||
// Allows loading image assets. See https://github.com/expo/expo/issues/31240
|
||||
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
|
||||
// or don't really matter. Because we want important warnings to actually be fixed, we disable
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './utils/polyfills';
|
||||
import './utils/initReact';
|
||||
import { AppRegistry } from 'react-native';
|
||||
import Root from './root';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
</dict>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string></string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
|
||||
@@ -321,6 +321,8 @@
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/.xcode.env",
|
||||
"$(SRCROOT)/.xcode.env.local",
|
||||
);
|
||||
name = "Bundle React Native code and images";
|
||||
outputPaths = (
|
||||
@@ -339,13 +341,12 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_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}/RNSVG/RNSVGFilters.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}/boost/boost_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactNativeFs/RNFS_PrivacyInfo.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.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_Regular.ttf",
|
||||
@@ -359,13 +360,12 @@
|
||||
"${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}/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}/RNSVGFilters.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}/boost_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNFS_PrivacyInfo.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}/FontAwesome5_Brands.ttf",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf",
|
||||
@@ -409,11 +409,15 @@
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Joplin/Pods-Joplin-frameworks.sh",
|
||||
"${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",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputPaths = (
|
||||
"${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",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -452,11 +456,16 @@
|
||||
inputFileListPaths = (
|
||||
);
|
||||
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";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-Joplin/ExpoModulesProvider.swift",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
@@ -653,6 +662,7 @@
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
};
|
||||
name = Debug;
|
||||
@@ -729,6 +739,7 @@
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
|
||||
@@ -44,16 +44,15 @@
|
||||
<false/>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
|
||||
<!-- Left over from before upgrading from RN 0.71, 0.73 -->
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>localhost</key>
|
||||
<key>api.joplincloud.local</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>api.joplincloud.local</key>
|
||||
<key>localhost</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
@@ -62,18 +61,22 @@
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<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>
|
||||
<string>To add geo-location information to a note. Can be disabled in app.</string>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
<string>To add geo-location information to a note. Can be disabled in app.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<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>
|
||||
<string>The images will be displayed on your notes.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>To allow attaching images to a note</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>To allow attaching voice recordings to a note</string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>AntDesign.ttf</string>
|
||||
@@ -86,6 +89,10 @@
|
||||
<string>MaterialDesignIcons.ttf</string>
|
||||
<string>MaterialCommunityIcons.ttf</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
@@ -109,11 +116,5 @@
|
||||
<string>Automatic</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
@@ -5,23 +5,33 @@ require File.join(File.dirname(`node --print "require.resolve('react-native/pack
|
||||
require 'json'
|
||||
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
|
||||
|
||||
ENV['RCT_NEW_ARCH_ENABLED'] = '0' if podfile_properties['newArchEnabled'] == 'false'
|
||||
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
|
||||
def ccache_enabled?(podfile_properties)
|
||||
# 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'
|
||||
install! 'cocoapods',
|
||||
:deterministic_uuids => false
|
||||
|
||||
prepare_react_native_project!
|
||||
|
||||
target 'Joplin' do
|
||||
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()"];
|
||||
else
|
||||
config_command = [
|
||||
'npx',
|
||||
'node',
|
||||
'--no-warnings',
|
||||
'--eval',
|
||||
'require(\'expo/bin/autolinking\')',
|
||||
'expo-modules-autolinking',
|
||||
'react-native-config',
|
||||
'--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 => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
|
||||
|
||||
|
||||
use_react_native!(
|
||||
:path => config[:reactNativePath],
|
||||
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
|
||||
# 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'
|
||||
|
||||
post_install do |installer|
|
||||
@@ -50,7 +61,7 @@ target 'Joplin' do
|
||||
installer,
|
||||
config[:reactNativePath],
|
||||
:mac_catalyst_enabled => false,
|
||||
# :ccache_enabled => true
|
||||
:ccache_enabled => ccache_enabled?(podfile_properties),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
+1049
-830
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>
|
||||
<string>com.apple.share-services</string>
|
||||
</dict>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -84,6 +84,7 @@ const emptyMockPackages = [
|
||||
'@joplin/react-native-saf-x',
|
||||
'expo-av',
|
||||
'expo-av/build/Audio',
|
||||
'expo-image-manipulator',
|
||||
];
|
||||
for (const packageName of emptyMockPackages) {
|
||||
jest.doMock(packageName, () => {
|
||||
@@ -130,7 +131,7 @@ mockIconLibrary('@react-native-vector-icons/fontawesome5', 'FontAwesome5');
|
||||
// Use a temporary folder instead.
|
||||
const tempDirectoryPath = path.join(tmpdir(), `appmobile-test-${uuid.createNano()}`);
|
||||
|
||||
jest.doMock('react-native-fs', () => {
|
||||
jest.doMock('@dr.pogodin/react-native-fs', () => {
|
||||
return {
|
||||
CachesDirectoryPath: tempDirectoryPath,
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ const localPackages = {
|
||||
'@joplin/tools': path.resolve(__dirname, '../tools/'),
|
||||
'@joplin/utils': path.resolve(__dirname, '../utils/'),
|
||||
'@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/react-native-saf-x': path.resolve(__dirname, '../react-native-saf-x/'),
|
||||
'@joplin/react-native-alarm-notification': path.resolve(__dirname, '../react-native-alarm-notification/'),
|
||||
|
||||
@@ -21,13 +21,14 @@
|
||||
"postinstall": "jetify"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bam.tech/react-native-image-resizer": "3.0.11",
|
||||
"@dr.pogodin/react-native-fs": "2.36.2",
|
||||
"@joplin/editor": "~3.6",
|
||||
"@joplin/lib": "~3.6",
|
||||
"@joplin/react-native-alarm-notification": "~3.6",
|
||||
"@joplin/react-native-saf-x": "~3.6",
|
||||
"@joplin/renderer": "~3.6",
|
||||
"@joplin/utils": "~3.6",
|
||||
"@joplin/whisper-voice-typing": "~3.6",
|
||||
"@js-draw/material-icons": "1.33.0",
|
||||
"@react-native-clipboard/clipboard": "1.16.3",
|
||||
"@react-native-community/datetimepicker": "8.4.7",
|
||||
@@ -47,40 +48,41 @@
|
||||
"crypto-browserify": "3.12.1",
|
||||
"deprecated-react-native-prop-types": "5.0.0",
|
||||
"events": "3.3.0",
|
||||
"expo": "53.0.23",
|
||||
"expo-av": "15.1.7",
|
||||
"expo-camera": "16.1.11",
|
||||
"expo-local-authentication": "16.0.5",
|
||||
"expo": "54.0.31",
|
||||
"expo-av": "16.0.8",
|
||||
"expo-camera": "17.0.10",
|
||||
"expo-image-manipulator": "14.0.8",
|
||||
"expo-local-authentication": "17.0.8",
|
||||
"js-draw": "1.33.0",
|
||||
"lodash": "4.17.21",
|
||||
"md5": "2.3.0",
|
||||
"path-browserify": "1.0.1",
|
||||
"prop-types": "15.8.1",
|
||||
"punycode": "2.3.1",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.2",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-device-info": "14.1.1",
|
||||
"react-native-dropdownalert": "5.2.0",
|
||||
"react-native-exit-app": "2.0.0",
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-fs": "2.20.0",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-image-picker": "8.2.1",
|
||||
"react-native-localize": "3.5.4",
|
||||
"react-native-modal-datetime-picker": "18.0.0",
|
||||
"react-native-nitro-modules": "0.33.2",
|
||||
"react-native-paper": "5.14.5",
|
||||
"react-native-popup-menu": "0.17.0",
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
"react-native-quick-base64": "2.2.2",
|
||||
"react-native-quick-crypto": "0.7.17",
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "5.6.2",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "12.2.0",
|
||||
"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-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-redux": "8.1.3",
|
||||
"redux": "4.2.1",
|
||||
@@ -101,18 +103,18 @@
|
||||
"@joplin/turndown": "~4.0.80",
|
||||
"@joplin/turndown-plugin-gfm": "~1.0.62",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.0",
|
||||
"@react-native-community/cli": "16.0.3",
|
||||
"@react-native-community/cli-platform-android": "16.0.3",
|
||||
"@react-native-community/cli-platform-ios": "16.0.3",
|
||||
"@react-native/babel-preset": "0.80.1",
|
||||
"@react-native/metro-config": "0.79.5",
|
||||
"@react-native/typescript-config": "0.80.2",
|
||||
"@react-native-community/cli": "20.0.0",
|
||||
"@react-native-community/cli-platform-android": "20.0.0",
|
||||
"@react-native-community/cli-platform-ios": "20.0.0",
|
||||
"@react-native/babel-preset": "0.81.5",
|
||||
"@react-native/metro-config": "0.81.5",
|
||||
"@react-native/typescript-config": "0.81.5",
|
||||
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
|
||||
"@testing-library/react-native": "13.2.0",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.0.14",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.164",
|
||||
"@types/tar-stream": "3.1.4",
|
||||
@@ -130,10 +132,10 @@
|
||||
"jsdom": "26.1.0",
|
||||
"nodemon": "3.1.10",
|
||||
"punycode": "2.3.1",
|
||||
"react-dom": "19.0.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native-web": "0.21.2",
|
||||
"react-refresh": "0.18.0",
|
||||
"react-test-renderer": "19.0.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"sharp": "0.34.4",
|
||||
"sqlite3": "5.1.6",
|
||||
"timers-browserify": "2.0.12",
|
||||
@@ -147,7 +149,7 @@
|
||||
"webpack-dev-server": "5.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"expo": {
|
||||
"autolinking": {
|
||||
@@ -157,7 +159,6 @@
|
||||
},
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native@~0.76.6",
|
||||
"react-native-reanimated@~3.16.1",
|
||||
"react-native-gesture-handler@~2.20.0",
|
||||
"react-native-screens@~4.4.0",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import PerformanceLogger from '@joplin/lib/PerformanceLogger';
|
||||
|
||||
shim.setReact(React);
|
||||
PerformanceLogger.onAppStartBegin();
|
||||
|
||||
import setupQuickActions from './setupQuickActions';
|
||||
@@ -695,12 +692,12 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
let disableSideMenuGestures = this.props.disableSideMenuGestures;
|
||||
|
||||
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;
|
||||
} else if (this.props.routeName === 'Config') {
|
||||
disableSideMenuGestures = true;
|
||||
} else {
|
||||
sideMenuContent = <SafeAreaView style={{ flex: 1, backgroundColor: theme.backgroundColor }}><SideMenuContent/></SafeAreaView>;
|
||||
sideMenuContent = <SideMenuContent/>;
|
||||
}
|
||||
|
||||
const appNavInit = {
|
||||
|
||||
@@ -71,7 +71,10 @@ const crypto: Crypto = {
|
||||
|
||||
digest: async (algorithm: Digest, data: Uint8Array) => {
|
||||
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();
|
||||
},
|
||||
|
||||
|
||||
@@ -3,35 +3,29 @@ import whisper from './whisper';
|
||||
import { dirname, join } from 'path';
|
||||
import { exists, mkdir, remove, writeFile } from 'fs-extra';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { NativeModules } from 'react-native';
|
||||
const SpeechToTextModule = NativeModules.SpeechToTextModule;
|
||||
|
||||
jest.mock('react-native', () => {
|
||||
const reactNative = jest.requireActual('react-native');
|
||||
const { testing__lastPrompt } = require('@joplin/whisper-voice-typing');
|
||||
|
||||
jest.mock('@joplin/whisper-voice-typing', () => {
|
||||
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 someId;
|
||||
return {
|
||||
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(),
|
||||
startRecording: jest.fn(),
|
||||
convertAvailable: jest.fn(() => ''),
|
||||
test: ()=>{},
|
||||
testing__lastPrompt: () => {
|
||||
return lastPrompt;
|
||||
},
|
||||
};
|
||||
|
||||
return reactNative;
|
||||
});
|
||||
|
||||
interface ModelConfig {
|
||||
@@ -135,6 +129,6 @@ describe('whisper', () => {
|
||||
});
|
||||
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 { rtrimSlashes } from '@joplin/utils/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 { languageCodeOnly, stringByLocale } from '@joplin/lib/locale';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const logger = Logger.create('voiceTyping/whisper');
|
||||
|
||||
const { SpeechToTextModule } = NativeModules;
|
||||
|
||||
class WhisperConfig {
|
||||
public prompts: Map<string, string> = new Map();
|
||||
public supportsShortAudioCtx = false;
|
||||
@@ -86,7 +85,7 @@ class Whisper implements VoiceTypingSession {
|
||||
private isFirstParagraph = true;
|
||||
|
||||
public constructor(
|
||||
private sessionId: number|null,
|
||||
private session: WhisperSession|null,
|
||||
private callbacks: SpeechToTextCallbacks,
|
||||
private config: WhisperConfig,
|
||||
) {
|
||||
@@ -124,18 +123,18 @@ class Whisper implements VoiceTypingSession {
|
||||
}
|
||||
|
||||
public async start() {
|
||||
if (this.sessionId === null) {
|
||||
if (this.session === null) {
|
||||
throw new Error('Session closed.');
|
||||
}
|
||||
try {
|
||||
logger.debug('starting recorder');
|
||||
await SpeechToTextModule.startRecording(this.sessionId);
|
||||
await this.session.open();
|
||||
logger.debug('recorder started');
|
||||
|
||||
const loopStartCounter = this.closeCounter;
|
||||
while (this.closeCounter === loopStartCounter && this.sessionId !== null) {
|
||||
while (this.closeCounter === loopStartCounter && this.session !== null) {
|
||||
logger.debug('reading block');
|
||||
const data: string = await SpeechToTextModule.convertNext(this.sessionId, 4);
|
||||
const data: string = await this.session.convertNext(4);
|
||||
this.onDataFinalize(data);
|
||||
|
||||
logger.debug('done reading block. Length', data?.length);
|
||||
@@ -148,13 +147,13 @@ class Whisper implements VoiceTypingSession {
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.sessionId === null) {
|
||||
if (this.session === null) {
|
||||
logger.debug('Session already closed.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data: string = await SpeechToTextModule.convertAvailable(this.sessionId);
|
||||
const data: string = await this.session.convertNext(null);
|
||||
this.onDataFinalize(data);
|
||||
} catch (error) {
|
||||
logger.error('Error stopping session: ', error);
|
||||
@@ -164,17 +163,17 @@ class Whisper implements VoiceTypingSession {
|
||||
}
|
||||
|
||||
public cancel() {
|
||||
if (this.sessionId === null) {
|
||||
if (this.session === null) {
|
||||
logger.debug('No session to cancel.');
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
logger.info('Closing session...');
|
||||
const sessionId = this.sessionId;
|
||||
this.sessionId = null;
|
||||
this.session.close();
|
||||
this.session = null;
|
||||
this.closeCounter ++;
|
||||
|
||||
return SpeechToTextModule.closeSession(sessionId);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +212,7 @@ const modelLocalFilepath = () => {
|
||||
};
|
||||
|
||||
const whisper: VoiceTypingProvider = {
|
||||
supported: () => !!SpeechToTextModule && Setting.value('buildFlag.voiceTypingEnabled'),
|
||||
supported: () => Platform.OS === 'android' && Setting.value('buildFlag.voiceTypingEnabled'),
|
||||
modelLocalFilepath: modelLocalFilepath,
|
||||
getDownloadUrl: (locale) => {
|
||||
const lang = languageCodeOnly(locale).toLowerCase();
|
||||
@@ -251,7 +250,7 @@ const whisper: VoiceTypingProvider = {
|
||||
|
||||
if (Setting.value('env') === Env.Dev) {
|
||||
try {
|
||||
await SpeechToTextModule.runTests();
|
||||
await testWhisper();
|
||||
} catch (error) {
|
||||
logger.error('Testing error', error);
|
||||
await shim.showErrorDialog(`Test failure: ${error}`);
|
||||
@@ -268,10 +267,10 @@ const whisper: VoiceTypingProvider = {
|
||||
}
|
||||
|
||||
logger.debug('Starting whisper session', config.supportsShortAudioCtx ? '(short audio context)' : '');
|
||||
const sessionId = await SpeechToTextModule.openSession(
|
||||
modelPath, locale, getPrompt(locale, config.prompts), config.supportsShortAudioCtx,
|
||||
);
|
||||
return new Whisper(sessionId, callbacks, config);
|
||||
const session = openSession({
|
||||
modelPath, locale, prompt: getPrompt(locale, config.prompts), shortAudioContext: config.supportsShortAudioCtx,
|
||||
});
|
||||
return new Whisper(session, callbacks, config);
|
||||
},
|
||||
modelName: 'whisper',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import FsDriverBase, { ReadDirStatsOptions } from '@joplin/lib/fs-driver-base';
|
||||
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 { Platform } from 'react-native';
|
||||
import tarCreate from './tarCreate';
|
||||
|
||||
@@ -310,7 +310,7 @@ export class WorkerApi {
|
||||
at = writer.getSize();
|
||||
}
|
||||
|
||||
write = (data: ArrayBufferLike) => writer.write(data, { at });
|
||||
write = (data: BufferSource) => writer.write(data, { at });
|
||||
close = () => writer.close();
|
||||
} catch (error) {
|
||||
// In some cases, createSyncAccessHandle isn't available. In other cases,
|
||||
@@ -318,7 +318,7 @@ export class WorkerApi {
|
||||
|
||||
logger.warn('Failed to createSyncAccessHandle', error);
|
||||
const writer = await handle.createWritable({ keepExistingData: options?.keepExistingData });
|
||||
write = (data: ArrayBufferLike) => writer.write(data);
|
||||
write = (data: BufferSource) => writer.write(data);
|
||||
close = () => writer.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import { fileUriToPath } from '@joplin/utils/url';
|
||||
import { Image as NativeImage, Platform } from 'react-native';
|
||||
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('/')) {
|
||||
uri = `file://${uri}`;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import shim from '@joplin/lib/shim';
|
||||
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 FsDriverWeb from '../fs-driver/fs-driver-rn.web';
|
||||
import getImageDimensions from './getImageDimensions';
|
||||
|
||||
const logger = Logger.create('resizeImage');
|
||||
|
||||
@@ -18,17 +19,23 @@ interface 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') {
|
||||
const image = await fileToImage(options.inputPath);
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
// Choose a scale factor such that the resized image fits within a
|
||||
// maxWidth x maxHeight box.
|
||||
const scale = Math.min(
|
||||
options.maxWidth / image.image.width,
|
||||
options.maxHeight / image.image.height,
|
||||
);
|
||||
const scale = computeScale(image.image);
|
||||
canvas.width = image.image.width * scale;
|
||||
canvas.height = image.image.height * scale;
|
||||
|
||||
@@ -54,18 +61,27 @@ const resizeImage = async (options: Options) => {
|
||||
image.free();
|
||||
}
|
||||
} else {
|
||||
const resizedImage = await ImageResizer.createResizedImage(
|
||||
options.inputPath,
|
||||
options.maxWidth,
|
||||
options.maxHeight,
|
||||
options.format,
|
||||
options.quality, // quality
|
||||
undefined, // rotation
|
||||
undefined, // outputPath
|
||||
true, // keep metadata
|
||||
);
|
||||
const originalSize = await getImageDimensions(options.inputPath);
|
||||
logger.debug('Processing image with size', originalSize.width, 'x', originalSize.height);
|
||||
|
||||
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(`Moving ${resizedImagePath} => ${options.outputPath}`);
|
||||
|
||||
|
||||
@@ -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 { Linking, Platform } from 'react-native';
|
||||
import crypto from '../../services/e2ee/crypto';
|
||||
const RNExitApp = require('react-native-exit-app').default;
|
||||
import { reloadAppAsync } from 'expo';
|
||||
|
||||
export default function shimInit() {
|
||||
shim.Geolocation = GeolocationReact;
|
||||
@@ -178,7 +178,7 @@ export default function shimInit() {
|
||||
};
|
||||
|
||||
shim.restartApp = () => {
|
||||
RNExitApp.exitApp();
|
||||
void reloadAppAsync();
|
||||
};
|
||||
|
||||
shimInitShared();
|
||||
|
||||
@@ -79,6 +79,7 @@ const buildSharedConfig = (hotReload: boolean): webpack.Configuration => {
|
||||
'@react-native-documents/picker': emptyLibraryMock,
|
||||
'react-native-exit-app': emptyLibraryMock,
|
||||
'expo-camera': emptyLibraryMock,
|
||||
'react-native-nitro-modules': emptyLibraryMock,
|
||||
'react-native-vector-icons/MaterialCommunityIcons': throwOnLoadLibraryMock,
|
||||
|
||||
// Workaround for applying serviceworker types to a single file.
|
||||
|
||||
@@ -220,7 +220,7 @@ function shimInit(options: ShimInitOptions = null) {
|
||||
if (shim.isElectron()) {
|
||||
return shim.electronBridge().showMessageBox(message, options ?? {});
|
||||
} else {
|
||||
throw new Error('Not implemented');
|
||||
throw new Error(`Not implemented: showMessageBox(${JSON.stringify(message)})`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.16.0",
|
||||
"@types/react-native": "0.64.19",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.70.6",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -241,6 +241,7 @@ xhdpi
|
||||
xxhdpi
|
||||
xxxhdpi
|
||||
scrollend
|
||||
pogodin
|
||||
youtube
|
||||
youtu
|
||||
nocookie
|
||||
|
||||
@@ -145,6 +145,7 @@ async function main() {
|
||||
await updatePackageVersion(`${rootDir}/packages/default-plugins/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/whisper-voice-typing/package.json`, majorMinorVersion, options);
|
||||
|
||||
if (options.updateVersion) {
|
||||
await updateGradleVersion(`${rootDir}/packages/app-mobile/android/app/build.gradle`, majorMinorVersion);
|
||||
|
||||
@@ -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 @@
|
||||
{}
|
||||
@@ -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);
|
||||
}
|
||||
+28
-34
@@ -1,4 +1,4 @@
|
||||
package net.cozic.joplin.audio
|
||||
package com.margelo.nitro.whispervoicetyping
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
@@ -10,22 +10,20 @@ import android.media.MediaRecorder.AudioSource
|
||||
import java.io.Closeable
|
||||
import kotlin.math.max
|
||||
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
|
||||
// Don't allow the unprocessed audio buffer to grow indefinitely -- discard
|
||||
// data if longer than this:
|
||||
private val maxLengthSeconds = 120
|
||||
private val maxRecorderBufferLengthSeconds = 20
|
||||
private val maxBufferSize = sampleRate * maxLengthSeconds
|
||||
private val buffer = FloatArray(maxBufferSize)
|
||||
private val maxBufferSizeFloats = sampleRate * maxLengthSeconds
|
||||
private val buffer = FloatArray(maxBufferSizeFloats)
|
||||
|
||||
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 {
|
||||
val permissionResult = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO)
|
||||
@@ -46,65 +44,61 @@ class AudioRecorder(context: Context) : Closeable {
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
// Use a smaller internal buffer size in the recorder
|
||||
.setBufferSizeInBytes(maxRecorderBufferLengthSeconds * sampleRate * Float.SIZE_BYTES)
|
||||
.build()
|
||||
|
||||
// Discards the first [samples] samples from the start of the buffer. Conceptually, this
|
||||
// advances the buffer's start point.
|
||||
private fun advanceStartBySamples(samples: Int) {
|
||||
val samplesClamped = min(samples, maxBufferSize)
|
||||
val remainingBuffer = buffer.sliceArray(samplesClamped until maxBufferSize)
|
||||
val samplesClamped = min(samples, maxBufferSizeFloats)
|
||||
val remainingBuffer = buffer.sliceArray(samplesClamped until maxBufferSizeFloats)
|
||||
|
||||
buffer.fill(0f, samplesClamped, maxBufferSize)
|
||||
buffer.fill(0f, samplesClamped, maxBufferSizeFloats)
|
||||
remainingBuffer.copyInto(buffer, 0)
|
||||
bufferWriteOffset = max(bufferWriteOffset - samplesClamped, 0)
|
||||
}
|
||||
|
||||
fun dropFirstSeconds(seconds: Double) {
|
||||
advanceStartBySamples((seconds * sampleRate).toInt())
|
||||
}
|
||||
|
||||
fun start() {
|
||||
recorder.startRecording()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
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)
|
||||
if (sizeRead > 0) {
|
||||
bufferWriteOffset += sizeRead
|
||||
}
|
||||
}
|
||||
|
||||
// Pulls all available data from the audio recorder's buffer
|
||||
// Returns a pointer to the buffered data
|
||||
fun pullAvailable(): FloatArray {
|
||||
read(maxBufferSize, AudioRecord.READ_NON_BLOCKING)
|
||||
|
||||
val result = bufferedData
|
||||
buffer.fill(0.0f, 0, maxBufferSize);
|
||||
bufferWriteOffset = 0
|
||||
return result
|
||||
read(maxBufferSizeFloats, AudioRecord.READ_NON_BLOCKING)
|
||||
return buffer.sliceArray(0 until bufferWriteOffset)
|
||||
}
|
||||
|
||||
fun pullNextSeconds(seconds: Double):FloatArray {
|
||||
val remainingSize = maxBufferSize - bufferWriteOffset
|
||||
// Sets the buffer write offset back to zero.
|
||||
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()
|
||||
|
||||
// If low on size, make more room.
|
||||
if (remainingSize < maxBufferSize / 3) {
|
||||
advanceStartBySamples(maxBufferSize / 3)
|
||||
if (remainingSize < maxBufferSizeFloats / 3) {
|
||||
advanceStartBySamples(maxBufferSizeFloats / 3)
|
||||
}
|
||||
|
||||
read(requestedSize, AudioRecord.READ_BLOCKING)
|
||||
return pullAvailable()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
recorder.stop()
|
||||
recorder.release()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val factory: AudioRecorderFactory = { context -> AudioRecorder(context) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -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()
|
||||
}
|
||||
}
|
||||
+22
@@ -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();
|
||||
+7
-7
@@ -1,10 +1,10 @@
|
||||
#include "WhisperSession.h"
|
||||
#include "WhisperSession.hpp"
|
||||
|
||||
#include <utility>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include "whisper.h"
|
||||
#include "findLongestSilence.h"
|
||||
#include "findLongestSilence.hpp"
|
||||
#include "androidUtil.h"
|
||||
|
||||
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) {
|
||||
// Update the local audio buffer
|
||||
for (int i = 0; i < sizeAudio; i++) {
|
||||
audioBuffer_.push_back(pAudio[i]);
|
||||
}
|
||||
void WhisperSession::addAudio(float* data, size_t count) {
|
||||
// See https://en.cppreference.com/w/cpp/algorithm/copy_n.html,
|
||||
// and the suggestions at https://stackoverflow.com/q/34552783.
|
||||
audioBuffer_.reserve(count);
|
||||
std::copy_n(data, count, std::back_inserter(audioBuffer_));
|
||||
}
|
||||
|
||||
std::string WhisperSession::transcribeAll() {
|
||||
+1
-1
@@ -10,7 +10,7 @@ public:
|
||||
WhisperSession(const std::string& modelPath, std::string lang, std::string prompt, bool shortAudioContext);
|
||||
~WhisperSession();
|
||||
// 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.
|
||||
std::string transcribeNextChunk();
|
||||
// Transcribes all buffered audio data that hasn't been finalized yet
|
||||
+12
@@ -1,5 +1,15 @@
|
||||
#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>
|
||||
|
||||
// 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 LOGI(...) __android_log_print(ANDROID_LOG_INFO, "Whisper::JNI", __VA_ARGS__);
|
||||
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "Whisper::JNI", __VA_ARGS__);
|
||||
|
||||
#endif
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
#include "findLongestSilence.h"
|
||||
#include "findLongestSilence.hpp"
|
||||
#include "androidUtil.h"
|
||||
|
||||
static void highpass(std::vector<float>& data, int sampleRate) {
|
||||
+3
-11
@@ -1,6 +1,7 @@
|
||||
#include "findLongestSilence_test.h"
|
||||
#include "findLongestSilence.h"
|
||||
#include "findLongestSilence_test.hpp"
|
||||
#include "findLongestSilence.hpp"
|
||||
#include "androidUtil.h"
|
||||
#include "testing.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -13,8 +14,6 @@ static void testToneWithPause();
|
||||
static void testSilence();
|
||||
static void testNoise();
|
||||
|
||||
static void fail(const std::string& message);
|
||||
|
||||
struct GeneratedAudio {
|
||||
std::vector<float> data;
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user