@onekeyfe/react-native-photo-library 0.0.0-stage → 3.0.166
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.
- package/LICENSE +21 -0
- package/README.md +27 -2
- package/ReactNativePhotoLibrary.podspec +36 -0
- package/android/CMakeLists.txt +24 -0
- package/android/build.gradle +132 -0
- package/android/gradle.properties +4 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/cpp-adapter.cpp +6 -0
- package/android/src/main/java/com/margelo/nitro/reactnativephotolibrary/ImagePhotoLibrary.kt +207 -0
- package/android/src/main/java/com/margelo/nitro/reactnativephotolibrary/PhotoLibraryException.kt +12 -0
- package/android/src/main/java/com/margelo/nitro/reactnativephotolibrary/ReactNativePhotoLibrary.kt +11 -0
- package/android/src/main/java/com/margelo/nitro/reactnativephotolibrary/ReactNativePhotoLibraryPackage.kt +22 -0
- package/docs/SPEC.md +108 -0
- package/ios/ImagePhotoLibrary.swift +80 -0
- package/ios/PhotoLibraryError.swift +11 -0
- package/ios/ReactNativePhotoLibrary.swift +15 -0
- package/ios/tests/ImagePhotoLibraryTests.swift +44 -0
- package/lib/module/ReactNativePhotoLibrary.nitro.js +4 -0
- package/lib/module/ReactNativePhotoLibrary.nitro.js.map +1 -0
- package/lib/module/index.js +52 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/ReactNativePhotoLibrary.nitro.d.ts +15 -0
- package/lib/typescript/src/ReactNativePhotoLibrary.nitro.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +18 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/nitro.json +17 -0
- package/nitrogen/generated/android/c++/JHybridReactNativePhotoLibrarySpec.cpp +105 -0
- package/nitrogen/generated/android/c++/JHybridReactNativePhotoLibrarySpec.hpp +65 -0
- package/nitrogen/generated/android/c++/JPhotoSavePermission.hpp +62 -0
- package/nitrogen/generated/android/c++/JPhotoSavePermissionStatus.hpp +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativephotolibrary/HybridReactNativePhotoLibrarySpec.kt +65 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativephotolibrary/PhotoSavePermission.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativephotolibrary/PhotoSavePermissionStatus.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativephotolibrary/reactnativephotolibraryOnLoad.kt +35 -0
- package/nitrogen/generated/android/reactnativephotolibrary+autolinking.cmake +81 -0
- package/nitrogen/generated/android/reactnativephotolibrary+autolinking.gradle +27 -0
- package/nitrogen/generated/android/reactnativephotolibraryOnLoad.cpp +54 -0
- package/nitrogen/generated/android/reactnativephotolibraryOnLoad.hpp +34 -0
- package/nitrogen/generated/ios/ReactNativePhotoLibrary+autolinking.rb +62 -0
- package/nitrogen/generated/ios/ReactNativePhotoLibrary-Swift-Cxx-Bridge.cpp +57 -0
- package/nitrogen/generated/ios/ReactNativePhotoLibrary-Swift-Cxx-Bridge.hpp +159 -0
- package/nitrogen/generated/ios/ReactNativePhotoLibrary-Swift-Cxx-Umbrella.hpp +51 -0
- package/nitrogen/generated/ios/ReactNativePhotoLibraryAutolinking.mm +33 -0
- package/nitrogen/generated/ios/ReactNativePhotoLibraryAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridReactNativePhotoLibrarySpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridReactNativePhotoLibrarySpecSwift.hpp +104 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_PhotoSavePermission.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridReactNativePhotoLibrarySpec.swift +57 -0
- package/nitrogen/generated/ios/swift/HybridReactNativePhotoLibrarySpec_cxx.swift +183 -0
- package/nitrogen/generated/ios/swift/PhotoSavePermission.swift +34 -0
- package/nitrogen/generated/ios/swift/PhotoSavePermissionStatus.swift +44 -0
- package/nitrogen/generated/shared/c++/HybridReactNativePhotoLibrarySpec.cpp +23 -0
- package/nitrogen/generated/shared/c++/HybridReactNativePhotoLibrarySpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/PhotoSavePermission.hpp +88 -0
- package/nitrogen/generated/shared/c++/PhotoSavePermissionStatus.hpp +80 -0
- package/package.json +171 -4
- package/src/ReactNativePhotoLibrary.nitro.ts +15 -0
- 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
|
-
#
|
|
1
|
+
# @onekeyfe/react-native-photo-library
|
|
2
2
|
|
|
3
|
-
|
|
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 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
@@ -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
|
+
}
|
package/android/src/main/java/com/margelo/nitro/reactnativephotolibrary/PhotoLibraryException.kt
ADDED
|
@@ -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
|
+
}
|
package/android/src/main/java/com/margelo/nitro/reactnativephotolibrary/ReactNativePhotoLibrary.kt
ADDED
|
@@ -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.
|