@onekeyfe/react-native-photo-library 0.0.0-stage → 3.0.165

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +27 -2
  3. package/ReactNativePhotoLibrary.podspec +36 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +132 -0
  6. package/android/gradle.properties +4 -0
  7. package/android/src/main/AndroidManifest.xml +1 -0
  8. package/android/src/main/cpp/cpp-adapter.cpp +6 -0
  9. package/android/src/main/java/com/margelo/nitro/reactnativephotolibrary/ImagePhotoLibrary.kt +207 -0
  10. package/android/src/main/java/com/margelo/nitro/reactnativephotolibrary/PhotoLibraryException.kt +12 -0
  11. package/android/src/main/java/com/margelo/nitro/reactnativephotolibrary/ReactNativePhotoLibrary.kt +11 -0
  12. package/android/src/main/java/com/margelo/nitro/reactnativephotolibrary/ReactNativePhotoLibraryPackage.kt +22 -0
  13. package/docs/SPEC.md +108 -0
  14. package/ios/ImagePhotoLibrary.swift +80 -0
  15. package/ios/PhotoLibraryError.swift +11 -0
  16. package/ios/ReactNativePhotoLibrary.swift +15 -0
  17. package/ios/tests/ImagePhotoLibraryTests.swift +44 -0
  18. package/lib/module/ReactNativePhotoLibrary.nitro.js +4 -0
  19. package/lib/module/ReactNativePhotoLibrary.nitro.js.map +1 -0
  20. package/lib/module/index.js +52 -0
  21. package/lib/module/index.js.map +1 -0
  22. package/lib/module/package.json +1 -0
  23. package/lib/typescript/package.json +1 -0
  24. package/lib/typescript/src/ReactNativePhotoLibrary.nitro.d.ts +15 -0
  25. package/lib/typescript/src/ReactNativePhotoLibrary.nitro.d.ts.map +1 -0
  26. package/lib/typescript/src/index.d.ts +18 -0
  27. package/lib/typescript/src/index.d.ts.map +1 -0
  28. package/nitro.json +17 -0
  29. package/nitrogen/generated/android/c++/JHybridReactNativePhotoLibrarySpec.cpp +105 -0
  30. package/nitrogen/generated/android/c++/JHybridReactNativePhotoLibrarySpec.hpp +65 -0
  31. package/nitrogen/generated/android/c++/JPhotoSavePermission.hpp +62 -0
  32. package/nitrogen/generated/android/c++/JPhotoSavePermissionStatus.hpp +61 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativephotolibrary/HybridReactNativePhotoLibrarySpec.kt +65 -0
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativephotolibrary/PhotoSavePermission.kt +56 -0
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativephotolibrary/PhotoSavePermissionStatus.kt +24 -0
  36. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativephotolibrary/reactnativephotolibraryOnLoad.kt +35 -0
  37. package/nitrogen/generated/android/reactnativephotolibrary+autolinking.cmake +81 -0
  38. package/nitrogen/generated/android/reactnativephotolibrary+autolinking.gradle +27 -0
  39. package/nitrogen/generated/android/reactnativephotolibraryOnLoad.cpp +54 -0
  40. package/nitrogen/generated/android/reactnativephotolibraryOnLoad.hpp +34 -0
  41. package/nitrogen/generated/ios/ReactNativePhotoLibrary+autolinking.rb +62 -0
  42. package/nitrogen/generated/ios/ReactNativePhotoLibrary-Swift-Cxx-Bridge.cpp +57 -0
  43. package/nitrogen/generated/ios/ReactNativePhotoLibrary-Swift-Cxx-Bridge.hpp +159 -0
  44. package/nitrogen/generated/ios/ReactNativePhotoLibrary-Swift-Cxx-Umbrella.hpp +51 -0
  45. package/nitrogen/generated/ios/ReactNativePhotoLibraryAutolinking.mm +33 -0
  46. package/nitrogen/generated/ios/ReactNativePhotoLibraryAutolinking.swift +26 -0
  47. package/nitrogen/generated/ios/c++/HybridReactNativePhotoLibrarySpecSwift.cpp +11 -0
  48. package/nitrogen/generated/ios/c++/HybridReactNativePhotoLibrarySpecSwift.hpp +104 -0
  49. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  50. package/nitrogen/generated/ios/swift/Func_void_PhotoSavePermission.swift +46 -0
  51. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  52. package/nitrogen/generated/ios/swift/HybridReactNativePhotoLibrarySpec.swift +57 -0
  53. package/nitrogen/generated/ios/swift/HybridReactNativePhotoLibrarySpec_cxx.swift +183 -0
  54. package/nitrogen/generated/ios/swift/PhotoSavePermission.swift +34 -0
  55. package/nitrogen/generated/ios/swift/PhotoSavePermissionStatus.swift +44 -0
  56. package/nitrogen/generated/shared/c++/HybridReactNativePhotoLibrarySpec.cpp +23 -0
  57. package/nitrogen/generated/shared/c++/HybridReactNativePhotoLibrarySpec.hpp +67 -0
  58. package/nitrogen/generated/shared/c++/PhotoSavePermission.hpp +88 -0
  59. package/nitrogen/generated/shared/c++/PhotoSavePermissionStatus.hpp +80 -0
  60. package/package.json +171 -4
  61. package/src/ReactNativePhotoLibrary.nitro.ts +15 -0
  62. package/src/index.tsx +79 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 OneKey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # @onekeyfe/react-native-photo-library
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Minimal add-only image saving. [Contract and acceptance](docs/SPEC.md).
4
+
5
+ ```ts
6
+ import PhotoLibrary from '@onekeyfe/react-native-photo-library';
7
+
8
+ // Invoke only after the user explicitly chooses Save.
9
+ const permission = await PhotoLibrary.requestSavePermission();
10
+ if (permission.status === 'granted') {
11
+ await PhotoLibrary.saveToLibrary('file:///local/image.png');
12
+ }
13
+ ```
14
+
15
+ `getSavePermission()` checks without prompting. `saveToLibrary()` never requests
16
+ permission, reads back assets, or removes its source. Inputs must be local images
17
+ of at most 64 MiB. Keep the source until completion. General file IO remains in
18
+ RNFS; use the separate OneKey picker for user selection and cropping.
19
+
20
+ iOS uses Photos `.addOnly`; keep `NSPhotoLibraryAddUsageDescription` in the App.
21
+ Android 10+ uses MediaStore with no storage permission. Android 8-9 requires App
22
+ `WRITE_EXTERNAL_STORAGE` capped at `maxSdkVersion=28`; request it only on Save.
23
+ iOS and Android 10+ do not request whole-library read authorization. On Android
24
+ 8-9, granting the legacy write permission also implicitly grants storage read
25
+ access, and the system prompt describes access to photos, media and files.
26
+ Permanent denial is returned as `{status: 'denied', canAskAgain: false}`.
27
+ `PhotoLibraryError.code` identifies permission, missing input,
28
+ activity/concurrency and save failures.
@@ -0,0 +1,36 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "ReactNativePhotoLibrary"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = package["homepage"]
10
+ s.license = package["license"]
11
+ s.authors = package["author"]
12
+
13
+ s.platforms = { :ios => min_ios_version_supported }
14
+ s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-photo-library.git", :tag => "#{s.version}" }
15
+
16
+ s.source_files = [
17
+ "ios/**/*.{swift}",
18
+ "ios/**/*.{m,mm}",
19
+ "cpp/**/*.{hpp,cpp}",
20
+ ]
21
+
22
+ s.exclude_files = "ios/tests/**/*"
23
+ s.frameworks = "Photos", "ImageIO", "UniformTypeIdentifiers"
24
+
25
+ s.dependency 'React-jsi'
26
+ s.dependency 'React-callinvoker'
27
+ s.dependency 'ReactNativeNativeLogger'
28
+
29
+ load 'nitrogen/generated/ios/ReactNativePhotoLibrary+autolinking.rb'
30
+ add_nitrogen_files(s)
31
+
32
+ install_modules_dependencies(s)
33
+ s.test_spec "Tests" do |test_spec|
34
+ test_spec.source_files = "ios/tests/**/*.swift"
35
+ end
36
+ end
@@ -0,0 +1,24 @@
1
+ project(reactnativephotolibrary)
2
+ cmake_minimum_required(VERSION 3.9.0)
3
+
4
+ set(PACKAGE_NAME reactnativephotolibrary)
5
+ set(CMAKE_VERBOSE_MAKEFILE ON)
6
+ set(CMAKE_CXX_STANDARD 20)
7
+
8
+ # Define C++ library and add all sources
9
+ add_library(${PACKAGE_NAME} SHARED src/main/cpp/cpp-adapter.cpp)
10
+
11
+ # Add Nitrogen specs :)
12
+ include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/reactnativephotolibrary+autolinking.cmake)
13
+
14
+ # Set up local includes
15
+ include_directories("src/main/cpp" "../cpp")
16
+
17
+ find_library(LOG_LIB log)
18
+
19
+ # Link all libraries together
20
+ target_link_libraries(
21
+ ${PACKAGE_NAME}
22
+ ${LOG_LIB}
23
+ android # <-- Android core
24
+ )
@@ -0,0 +1,132 @@
1
+ buildscript {
2
+ ext.getExtOrDefault = {name ->
3
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['ReactNativePhotoLibrary_' + name]
4
+ }
5
+
6
+ repositories {
7
+ google()
8
+ mavenCentral()
9
+ }
10
+
11
+ dependencies {
12
+ classpath "com.android.tools.build:gradle:8.7.2"
13
+ // noinspection DifferentKotlinGradleVersion
14
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
15
+ }
16
+ }
17
+
18
+ def reactNativeArchitectures() {
19
+ def value = rootProject.getProperties().get("reactNativeArchitectures")
20
+ return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
21
+ }
22
+
23
+ apply plugin: "com.android.library"
24
+ apply plugin: "kotlin-android"
25
+ apply from: '../nitrogen/generated/android/reactnativephotolibrary+autolinking.gradle'
26
+
27
+ apply plugin: "com.facebook.react"
28
+
29
+ def getExtOrIntegerDefault(name) {
30
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["ReactNativePhotoLibrary_" + name]).toInteger()
31
+ }
32
+
33
+ android {
34
+ namespace "com.margelo.nitro.reactnativephotolibrary"
35
+
36
+ compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
37
+
38
+ defaultConfig {
39
+ minSdkVersion getExtOrIntegerDefault("minSdkVersion")
40
+ targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
41
+
42
+ externalNativeBuild {
43
+ cmake {
44
+ cppFlags "-frtti -fexceptions -Wall -fstack-protector-all"
45
+ arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
46
+ abiFilters (*reactNativeArchitectures())
47
+
48
+ buildTypes {
49
+ debug {
50
+ cppFlags "-O1 -g"
51
+ }
52
+ release {
53
+ cppFlags "-O2"
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ externalNativeBuild {
61
+ cmake {
62
+ path "CMakeLists.txt"
63
+ }
64
+ }
65
+
66
+ packagingOptions {
67
+ excludes = [
68
+ "META-INF",
69
+ "META-INF/**",
70
+ "**/libc++_shared.so",
71
+ "**/libfbjni.so",
72
+ "**/libjsi.so",
73
+ "**/libfolly_json.so",
74
+ "**/libfolly_runtime.so",
75
+ "**/libglog.so",
76
+ "**/libhermes.so",
77
+ "**/libhermes-executor-debug.so",
78
+ "**/libhermes_executor.so",
79
+ "**/libreactnative.so",
80
+ "**/libreactnativejni.so",
81
+ "**/libturbomodulejsijni.so",
82
+ "**/libreact_nativemodule_core.so",
83
+ "**/libjscexecutor.so"
84
+ ]
85
+ }
86
+
87
+ buildFeatures {
88
+ buildConfig true
89
+ prefab true
90
+ }
91
+
92
+ buildTypes {
93
+ release {
94
+ minifyEnabled false
95
+ }
96
+ }
97
+
98
+ lintOptions {
99
+ disable "GradleCompatible"
100
+ }
101
+
102
+ compileOptions {
103
+ sourceCompatibility JavaVersion.VERSION_1_8
104
+ targetCompatibility JavaVersion.VERSION_1_8
105
+ }
106
+
107
+ sourceSets {
108
+ main {
109
+ java.srcDirs += [
110
+ "generated/java",
111
+ "generated/jni"
112
+ ]
113
+ }
114
+ }
115
+ }
116
+
117
+ repositories {
118
+ mavenCentral()
119
+ google()
120
+ }
121
+
122
+ def kotlin_version = getExtOrDefault("kotlinVersion")
123
+
124
+ dependencies {
125
+ implementation "androidx.activity:activity:1.10.1"
126
+
127
+ implementation "com.facebook.react:react-android"
128
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
129
+ implementation project(":react-native-nitro-modules")
130
+
131
+ implementation project(":onekeyfe_react-native-native-logger")
132
+ }
@@ -0,0 +1,4 @@
1
+ ReactNativePhotoLibrary_kotlinVersion=1.9.25
2
+ ReactNativePhotoLibrary_compileSdkVersion=35
3
+ ReactNativePhotoLibrary_targetSdkVersion=35
4
+ ReactNativePhotoLibrary_minSdkVersion=24
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,6 @@
1
+ #include <jni.h>
2
+ #include "reactnativephotolibraryOnLoad.hpp"
3
+
4
+ extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) {
5
+ return margelo::nitro::reactnativephotolibrary::initialize(vm);
6
+ }
@@ -0,0 +1,207 @@
1
+ package com.margelo.nitro.reactnativephotolibrary
2
+
3
+ import android.Manifest
4
+ import android.app.Activity
5
+ import android.graphics.BitmapFactory
6
+ import com.facebook.react.ReactApplication
7
+ import android.content.ContentValues
8
+ import android.content.Context
9
+ import android.content.pm.PackageManager
10
+ import android.media.MediaScannerConnection
11
+ import android.net.Uri
12
+ import android.os.Build
13
+ import android.os.Environment
14
+ import android.os.Handler
15
+ import android.os.Looper
16
+ import android.provider.MediaStore
17
+ import android.webkit.MimeTypeMap
18
+ import androidx.activity.ComponentActivity
19
+ import androidx.activity.result.ActivityResultLauncher
20
+ import androidx.activity.result.contract.ActivityResultContracts
21
+ import androidx.core.app.ActivityCompat
22
+ import androidx.core.content.ContextCompat
23
+ import androidx.lifecycle.DefaultLifecycleObserver
24
+ import androidx.lifecycle.LifecycleOwner
25
+ import com.margelo.nitro.NitroModules
26
+ import com.margelo.nitro.core.Promise
27
+ import java.io.File
28
+ import java.util.UUID
29
+ import java.util.concurrent.CountDownLatch
30
+ import java.util.concurrent.Executors
31
+ import java.util.concurrent.TimeUnit
32
+
33
+ internal object ImagePhotoLibrary {
34
+ private val mainHandler = Handler(Looper.getMainLooper())
35
+ private val saveExecutor = Executors.newSingleThreadExecutor()
36
+ private var permissionPending = false
37
+ private const val REQUESTED = "writePermissionRequested"
38
+
39
+ private fun context(): Context = NitroModules.applicationContext ?: throw PhotoLibraryException.noActivity()
40
+
41
+ private fun foregroundActivity(): Activity? {
42
+ // Nitro's global context can belong to bg; ReactHost owns the UI activity.
43
+ return (context().applicationContext as? ReactApplication)?.reactHost?.currentReactContext?.currentActivity
44
+ ?: NitroModules.applicationContext?.currentActivity
45
+ }
46
+
47
+ private fun imageMime(file: File): String {
48
+ if (!file.isFile || file.length() <= 0 || file.length() > 64 * 1024 * 1024) {
49
+ throw PhotoLibraryException.noImageData()
50
+ }
51
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
52
+ BitmapFactory.decodeFile(file.absolutePath, bounds)
53
+ val mime = bounds.outMimeType
54
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0 || mime?.startsWith("image/") != true) {
55
+ throw PhotoLibraryException.noImageData()
56
+ }
57
+ return mime
58
+ }
59
+
60
+ fun permission(): PhotoSavePermission {
61
+ if (Build.VERSION.SDK_INT >= 29) return PhotoSavePermission(PhotoSavePermissionStatus.GRANTED, true)
62
+ val context = context()
63
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
64
+ return PhotoSavePermission(PhotoSavePermissionStatus.GRANTED, true)
65
+ }
66
+ val requested = context.getSharedPreferences("onekey-photo-library", Context.MODE_PRIVATE).getBoolean(REQUESTED, false)
67
+ if (!requested) return PhotoSavePermission(PhotoSavePermissionStatus.UNDETERMINED, true)
68
+ val activity = foregroundActivity() ?: throw PhotoLibraryException.noActivity()
69
+ return PhotoSavePermission(
70
+ PhotoSavePermissionStatus.DENIED,
71
+ ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE),
72
+ )
73
+ }
74
+
75
+ fun getPermission(): Promise<PhotoSavePermission> {
76
+ val promise = Promise<PhotoSavePermission>()
77
+ mainHandler.post {
78
+ try { promise.resolve(permission()) } catch (error: Exception) { promise.reject(error) }
79
+ }
80
+ return promise
81
+ }
82
+
83
+ fun requestPermission(): Promise<PhotoSavePermission> {
84
+ val promise = Promise<PhotoSavePermission>()
85
+ mainHandler.post {
86
+ var launcher: ActivityResultLauncher<String>? = null
87
+ var observer: DefaultLifecycleObserver? = null
88
+ val activity = foregroundActivity() as? ComponentActivity
89
+ var finished = false
90
+ var ownsRequest = false
91
+ fun finish(error: Exception? = null) {
92
+ if (finished) return
93
+ finished = true
94
+ if (ownsRequest) permissionPending = false
95
+ observer?.let { activity?.lifecycle?.removeObserver(it) }
96
+ mainHandler.post { launcher?.unregister() }
97
+ try {
98
+ if (error == null) promise.resolve(permission()) else promise.reject(error)
99
+ } catch (failure: Exception) { promise.reject(failure) }
100
+ }
101
+ try {
102
+ val current = permission()
103
+ if (current.status == PhotoSavePermissionStatus.GRANTED || !current.canAskAgain) {
104
+ promise.resolve(current)
105
+ return@post
106
+ }
107
+ if (permissionPending) {
108
+ promise.reject(PhotoLibraryException.inProgress())
109
+ return@post
110
+ }
111
+ if (activity == null || activity.isFinishing || activity.isDestroyed) {
112
+ promise.reject(PhotoLibraryException.noActivity())
113
+ return@post
114
+ }
115
+ permissionPending = true
116
+ ownsRequest = true
117
+ observer = object : DefaultLifecycleObserver {
118
+ override fun onDestroy(owner: LifecycleOwner) { finish(PhotoLibraryException.noActivity()) }
119
+ }
120
+ activity.lifecycle.addObserver(observer!!)
121
+ launcher = activity.activityResultRegistry.register(
122
+ "onekey-photo-save-${UUID.randomUUID()}", ActivityResultContracts.RequestPermission(),
123
+ ) { finish() }
124
+ val preferences = context().getSharedPreferences("onekey-photo-library", Context.MODE_PRIVATE)
125
+ val previouslyRequested = preferences.getBoolean(REQUESTED, false)
126
+ preferences.edit().putBoolean(REQUESTED, true).apply()
127
+ try {
128
+ launcher!!.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
129
+ } catch (error: Exception) {
130
+ preferences.edit().putBoolean(REQUESTED, previouslyRequested).apply()
131
+ throw error
132
+ }
133
+ } catch (error: Exception) { finish(error) }
134
+ }
135
+ return promise
136
+ }
137
+
138
+ fun save(path: String): Promise<Unit> {
139
+ val promise = Promise<Unit>()
140
+ saveExecutor.execute {
141
+ try {
142
+ val context = context()
143
+ if (Build.VERSION.SDK_INT < 29 && ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
144
+ throw PhotoLibraryException.noLibraryPermission()
145
+ }
146
+ val file = when {
147
+ path.startsWith("/") -> File(path)
148
+ path.startsWith("file://") -> Uri.parse(path).let { uri ->
149
+ if (uri.authority.isNullOrEmpty() || uri.authority == "localhost") uri.path?.let { File(it) } else null
150
+ }
151
+ else -> null
152
+ } ?: throw PhotoLibraryException.noImageData()
153
+ val mime = imageMime(file)
154
+ val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "img"
155
+ val name = "onekey-${UUID.randomUUID()}.$extension"
156
+ if (Build.VERSION.SDK_INT >= 29) saveModern(context, file, mime, name) else saveLegacy(context, file, mime, name)
157
+ promise.resolve(Unit)
158
+ } catch (error: PhotoLibraryException) {
159
+ promise.reject(error)
160
+ } catch (error: Exception) {
161
+ promise.reject(PhotoLibraryException.cannotSaveImage(error))
162
+ }
163
+ }
164
+ return promise
165
+ }
166
+
167
+ private fun saveModern(context: Context, source: File, mime: String, name: String) {
168
+ val resolver = context.contentResolver
169
+ val values = ContentValues().apply {
170
+ put(MediaStore.Images.Media.DISPLAY_NAME, name)
171
+ put(MediaStore.Images.Media.MIME_TYPE, mime)
172
+ put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/OneKey")
173
+ put(MediaStore.Images.Media.IS_PENDING, 1)
174
+ }
175
+ val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
176
+ ?: throw PhotoLibraryException.cannotSaveImage()
177
+ try {
178
+ val stream = resolver.openOutputStream(uri) ?: throw PhotoLibraryException.cannotSaveImage()
179
+ stream.use { output -> source.inputStream().use { it.copyTo(output) } }
180
+ val published = ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }
181
+ if (resolver.update(uri, published, null, null) != 1) throw PhotoLibraryException.cannotSaveImage()
182
+ } catch (error: Exception) {
183
+ try { resolver.delete(uri, null, null) } catch (_: Exception) { }
184
+ throw error
185
+ }
186
+ }
187
+
188
+ @Suppress("DEPRECATION")
189
+ private fun saveLegacy(context: Context, source: File, mime: String, name: String) {
190
+ val directory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), "OneKey")
191
+ if (!directory.isDirectory && !directory.mkdirs()) throw PhotoLibraryException.cannotSaveImage()
192
+ val destination = File(directory, name)
193
+ try {
194
+ source.inputStream().use { input -> destination.outputStream().use { input.copyTo(it) } }
195
+ val scanned = CountDownLatch(1)
196
+ var savedUri: Uri? = null
197
+ MediaScannerConnection.scanFile(context, arrayOf(destination.absolutePath), arrayOf(mime)) { _, uri ->
198
+ savedUri = uri
199
+ scanned.countDown()
200
+ }
201
+ if (!scanned.await(30, TimeUnit.SECONDS) || savedUri == null) throw PhotoLibraryException.cannotSaveImage()
202
+ } catch (error: Exception) {
203
+ destination.delete()
204
+ throw error
205
+ }
206
+ }
207
+ }
@@ -0,0 +1,12 @@
1
+ package com.margelo.nitro.reactnativephotolibrary
2
+
3
+ internal class PhotoLibraryException(code: String, message: String, cause: Throwable? = null) :
4
+ Exception("$code: $message", cause) {
5
+ companion object {
6
+ fun noActivity() = PhotoLibraryException("E_ACTIVITY_DOES_NOT_EXIST", "Activity doesn't exist")
7
+ fun inProgress() = PhotoLibraryException("E_PERMISSION_IN_PROGRESS", "Another save permission request is pending")
8
+ fun noImageData() = PhotoLibraryException("E_NO_IMAGE_DATA_FOUND", "Cannot find image data")
9
+ fun noLibraryPermission() = PhotoLibraryException("E_NO_LIBRARY_PERMISSION", "Adding photos is not authorized")
10
+ fun cannotSaveImage(cause: Throwable? = null) = PhotoLibraryException("E_CANNOT_SAVE_IMAGE", "Cannot save image", cause)
11
+ }
12
+ }
@@ -0,0 +1,11 @@
1
+ package com.margelo.nitro.reactnativephotolibrary
2
+
3
+ import com.facebook.proguard.annotations.DoNotStrip
4
+ import com.margelo.nitro.core.Promise
5
+
6
+ @DoNotStrip
7
+ class ReactNativePhotoLibrary : HybridReactNativePhotoLibrarySpec() {
8
+ override fun getSavePermission(): Promise<PhotoSavePermission> = ImagePhotoLibrary.getPermission()
9
+ override fun requestSavePermission(): Promise<PhotoSavePermission> = ImagePhotoLibrary.requestPermission()
10
+ override fun saveToLibrary(path: String): Promise<Unit> = ImagePhotoLibrary.save(path)
11
+ }
@@ -0,0 +1,22 @@
1
+ package com.margelo.nitro.reactnativephotolibrary
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfoProvider
7
+
8
+ class ReactNativePhotoLibraryPackage : BaseReactPackage() {
9
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
10
+ return null
11
+ }
12
+
13
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
14
+ return ReactModuleInfoProvider { HashMap() }
15
+ }
16
+
17
+ companion object {
18
+ init {
19
+ System.loadLibrary("reactnativephotolibrary")
20
+ }
21
+ }
22
+ }
package/docs/SPEC.md ADDED
@@ -0,0 +1,108 @@
1
+ # Add-only photo saving
2
+
3
+ Status: Implemented extraction of the picker save implementation. After the
4
+ latest main merge, JS tests, types, codegen and Android native builds passed;
5
+ all nine iOS SDK XCTest suites, including the three photo-library
6
+ validation/permission-mapping cases, passed in CI at `a8e9fd9d`. API 36 saves
7
+ through MediaStore without a permission prompt, and
8
+ failed input rejects with the documented code. API 28 saving and first
9
+ grant/denial were exercised; permanent denial and lifecycle/failure cases remain
10
+ pending. Actual iOS App add-only authorization and saving are unverified because
11
+ the local App build failed in ExpoModulesJSI. SDK checks do not replace App UI
12
+ acceptance or final release artifact inspection.
13
+
14
+ ## Purpose and boundaries
15
+
16
+ Save one caller-owned local image to the system photo library on iOS/Android.
17
+ Only getSavePermission, requestSavePermission and saveToLibrary are public
18
+ operations. No selection, cropping, album listing, asset readback, deletion,
19
+ remote download, video or general filesystem API is provided. Generic app file
20
+ operations remain in RNFS. OS-required stream writes are part of the save
21
+ transaction; no intermediate image copy is created. Picker item-provider copies
22
+ remain in the picker because they must finish before the native callback returns;
23
+ there is no second consumer requiring a shared temporary-copy helper.
24
+
25
+ Callers use the foreground/main JS runtime. main and bg initialize independently
26
+ and have separate JS objects/heaps. Native save queues and Android pending
27
+ permission ownership are process-shared; no bg readiness is assumed. Import is
28
+ lazy and performs no I/O or permission request.
29
+
30
+ ## Public API and defaults
31
+
32
+ - getSavePermission(): Promise<PhotoSavePermission>, no prompt.
33
+ - requestSavePermission(): Promise<PhotoSavePermission>, called only after an
34
+ explicit user save action. Permission is never requested by import or save.
35
+ - saveToLibrary(path: string): Promise<void>, requires existing save permission.
36
+ Accepts an absolute local path or file:// URI, and resolves after OS completion.
37
+ - PhotoSavePermission: {status: 'granted' | 'denied' | 'undetermined',
38
+ canAskAgain: boolean}. Denied is never translated to successful save.
39
+ - PhotoLibraryError.code is E_NO_LIBRARY_PERMISSION, E_NO_IMAGE_DATA_FOUND,
40
+ E_CANNOT_SAVE_IMAGE, E_PERMISSION_IN_PROGRESS, E_ACTIVITY_DOES_NOT_EXIST or
41
+ E_UNKNOWN. No raw native stack or file path is exposed in library errors.
42
+ - Missing, non-image or over-64-MiB input rejects E_NO_IMAGE_DATA_FOUND. Metadata
43
+ must identify an image with positive dimensions; no full pixel decode occurs.
44
+ Callers must keep the source unchanged and present until the promise settles.
45
+
46
+ ## Platform contract
47
+
48
+ | Behavior | iOS | Android |
49
+ | --- | --- | --- |
50
+ | Authorization | PHPhotoLibrary .addOnly | API 29+: granted without prompt; API 26-28: WRITE_EXTERNAL_STORAGE |
51
+ | Permanent denial | denied/restricted, canAskAgain false | Previously requested plus OS rationale false |
52
+ | Save | PHAssetCreationRequest.addResource, no readback | API 29+: MediaStore pending insert/write/publish; API 26-28: Pictures/OneKey + scan |
53
+ | Declaration | App keeps NSPhotoLibraryAddUsageDescription | App declares WRITE_EXTERNAL_STORAGE with maxSdkVersion 28 |
54
+
55
+ The module declares no Android storage/media-read permission. App configuration
56
+ owns the legacy write declaration and iOS usage strings. No NSPhotoLibraryUsageDescription
57
+ is needed. Android permission launch resolves the UI ReactHost activity rather
58
+ than relying on the global Nitro context (which may belong to bg).
59
+
60
+ On Android 8-9, the OS legacy storage permission group is broader than the APK
61
+ declaration: granting WRITE_EXTERNAL_STORAGE also grants implicit read access.
62
+ The maxSdkVersion cap limits this compatibility behavior to API 28 and below.
63
+ API 29+ uses MediaStore without a storage permission request.
64
+
65
+ ## Lifecycle, concurrency and ownership
66
+
67
+ Permissions launch on the UI thread. Android owns at most one pending request
68
+ process-wide, rejects overlapping requests with E_PERMISSION_IN_PROGRESS, and
69
+ unregisters the launcher/observer on completion or activity destruction. Teardown
70
+ rejects E_ACTIVITY_DOES_NOT_EXIST. Each promise settles at most once.
71
+ Saves run serially on one background queue per platform; no retry or app cache.
72
+ Android persists only whether legacy write authorization has been requested, in
73
+ onekey-photo-library/writePermissionRequested (preserved from the picker).
74
+ UUID destination names avoid overwriting another save. Source files are never
75
+ removed. No JS cache, DB schema, migration or asset index is introduced.
76
+
77
+ ## Failure and resources
78
+
79
+ Modern Android deletes failed pending rows best-effort and publishes only after
80
+ stream completion. Legacy Android removes failed output, waits at most 30 seconds
81
+ for media scanning, and rejects null/late scan results. A late OS callback can
82
+ leave a stale OS index entry after timeout; the failed file is removed.
83
+ iOS failures reject without fetching the newly created asset. No automatic retry.
84
+ Streaming uses bounded buffers; metadata and save I/O stay off the UI thread.
85
+ Input cap is 64 MiB. No unmeasured memory/latency claim is made. Paths and image
86
+ bytes are never logged by this package. Queued sources remain caller-owned.
87
+
88
+ ## Conformance and acceptance
89
+
90
+ Contract locations: Nitro spec, lazy JS wrapper, platform ReactNativePhotoLibrary
91
+ and ImagePhotoLibrary implementations. Focused JS checks cover native permission
92
+ results, save-error mapping, and absence of implicit permission requests. Native
93
+ checks cover input validation and iOS permission mapping; bridge generation,
94
+ TypeScript and native builds cover registration and compilation.
95
+ Required runtime cases: iOS first add-only grant/deny/permanent deny and saved
96
+ asset, with full-library access denied; Android API 28 first grant/deny/permanent
97
+ deny and scan completion; API 29+ save without prompt and no media-read grants;
98
+ concurrent permission request, activity teardown, missing/oversized source,
99
+ cleanup after failed write. Final App APK/AAB and iOS Info.plist must be inspected
100
+ independently. Automated/source checks do not prove those UI cases.
101
+
102
+ ## Template deviations
103
+
104
+ The repository create:module template is used. Placeholder add API is replaced
105
+ with the three save APIs. Local package Babel removes an undeclared module-resolver
106
+ plugin, ESLint uses the repository's working FlatCompat setup, and RN devDependency
107
+ matches the root patched RN version. A Photos test spec/frameworks and Android
108
+ activity-result dependency are required for the extracted behavior.