mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-25 02:47:28 +00:00
Mobile: Upgrade to React Native 0.81 (#14232)
This commit is contained in:
1 parent
af2926b634
commit
00248a9177
652 files changed
+3984
-2911
No files matched your search
@@ -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
|
||||
|
||||
+1048
-829
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) {
|
||||
File renamed without changes.
+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);
|
||||
File renamed without changes.
@@ -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
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 652 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user