expo-clipboard 2.1.1 → 3.0.0
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/CHANGELOG.md +24 -1
- package/README.md +1 -1
- package/android/build.gradle +33 -12
- package/android/src/main/AndroidManifest.xml +12 -1
- package/android/src/main/java/expo/modules/clipboard/ClipboardExceptions.kt +23 -0
- package/android/src/main/java/expo/modules/clipboard/ClipboardFileProvider.kt +360 -0
- package/android/src/main/java/expo/modules/clipboard/ClipboardImage.kt +163 -0
- package/android/src/main/java/expo/modules/clipboard/ClipboardModule.kt +246 -24
- package/android/src/main/java/expo/modules/clipboard/ClipboardOptions.kt +44 -0
- package/android/src/main/res/xml/clipboard_provider_paths.xml +5 -0
- package/build/Clipboard.d.ts +98 -7
- package/build/Clipboard.d.ts.map +1 -0
- package/build/Clipboard.js +150 -11
- package/build/Clipboard.js.map +1 -1
- package/build/Clipboard.types.d.ts +74 -0
- package/build/Clipboard.types.d.ts.map +1 -0
- package/build/Clipboard.types.js +22 -0
- package/build/Clipboard.types.js.map +1 -0
- package/build/ExpoClipboard.d.ts +1 -0
- package/build/ExpoClipboard.d.ts.map +1 -0
- package/build/ExpoClipboard.web.d.ts +2 -8
- package/build/ExpoClipboard.web.d.ts.map +1 -0
- package/build/ExpoClipboard.web.js +1 -38
- package/build/ExpoClipboard.web.js.map +1 -1
- package/build/web/ClipboardModule.d.ts +15 -0
- package/build/web/ClipboardModule.d.ts.map +1 -0
- package/build/web/ClipboardModule.js +177 -0
- package/build/web/ClipboardModule.js.map +1 -0
- package/build/web/Exceptions.d.ts +14 -0
- package/build/web/Exceptions.d.ts.map +1 -0
- package/build/web/Exceptions.js +22 -0
- package/build/web/Exceptions.js.map +1 -0
- package/build/web/Utils.d.ts +18 -0
- package/build/web/Utils.d.ts.map +1 -0
- package/build/web/Utils.js +79 -0
- package/build/web/Utils.js.map +1 -0
- package/expo-module.config.json +3 -0
- package/ios/ClipboardExceptions.swift +15 -0
- package/ios/ClipboardModule.swift +136 -0
- package/ios/ClipboardOptions.swift +40 -0
- package/ios/{EXClipboard.podspec → ExpoClipboard.podspec} +3 -3
- package/ios/NSAttributedString+utilities.swift +34 -0
- package/ios/UIPasteboard+html.swift +47 -0
- package/package.json +2 -2
- package/src/Clipboard.ts +176 -12
- package/src/Clipboard.types.ts +80 -0
- package/src/ExpoClipboard.web.ts +1 -35
- package/src/web/ClipboardModule.ts +198 -0
- package/src/web/Exceptions.ts +25 -0
- package/src/web/Utils.ts +87 -0
- package/android/src/main/java/expo/modules/clipboard/ClipboardEventEmitter.kt +0 -49
- package/android/src/main/java/expo/modules/clipboard/ClipboardPackage.kt +0 -12
- package/ios/EXClipboard/ClipboardModule.swift +0 -42
- package/ios/EXClipboard/EXClipboardModule.h +0 -8
- package/ios/EXClipboard/EXClipboardModule.m +0 -88
package/CHANGELOG.md
CHANGED
|
@@ -10,7 +10,30 @@
|
|
|
10
10
|
|
|
11
11
|
### 💡 Others
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## 3.0.0 — 2022-04-18
|
|
14
|
+
|
|
15
|
+
### 🛠 Breaking changes
|
|
16
|
+
|
|
17
|
+
- The `content` property of the clipboard event listener is now deprecated and always returns empty string and logs a warning message to the console. Use `getStringAsync()` instead.
|
|
18
|
+
|
|
19
|
+
### 🎉 New features
|
|
20
|
+
|
|
21
|
+
- Native module on Android is now written in Kotlin using the new API. ([#16269](https://github.com/expo/expo/pull/16269) by [@barthap](https://github.com/barthap))
|
|
22
|
+
- Added support for setting and getting images (`setImageAsync`, `hasImageAsync`, `getImageAsync`). ([#16391](https://github.com/expo/expo/pull/16391), [#16413](https://github.com/expo/expo/pull/16413), [#16481](https://github.com/expo/expo/pull/16481) by [@barthap](https://github.com/barthap))
|
|
23
|
+
- On iOS added support for setting and getting URLs (`setUrlAsync`, `hasUrlAsync`, `getUrlAsync`). ([#16391](https://github.com/expo/expo/pull/16391) by [@graszka22](https://github.com/graszka22), [@barthap](https://github.com/barthap))
|
|
24
|
+
- Added new method `hasStringAsync` that checks whether clipboard has text content. ([#16524](https://github.com/expo/expo/pull/16524) by [@barthap](https://github.com/barthap))
|
|
25
|
+
- Added support for HTML content in `getStringAsync` and `setStringAsync`. ([#16551](https://github.com/expo/expo/pull/16551), [#16687](https://github.com/expo/expo/pull/16687) by [@barthap](https://github.com/barthap))
|
|
26
|
+
- Added new property `contentTypes` to the clipboard event listener describing contents of the clipboard. ([#16787](https://github.com/expo/expo/pull/16787) by [@barthap](https://github.com/barthap))
|
|
27
|
+
|
|
28
|
+
### ⚠ Notices
|
|
29
|
+
|
|
30
|
+
- Deprecated `setString`. Use `setStringAsync` instead. ([#16320](https://github.com/expo/expo/pull/16320) by [@barthap](https://github.com/barthap))
|
|
31
|
+
|
|
32
|
+
### ⚠️ Notices
|
|
33
|
+
|
|
34
|
+
- On Android bump `compileSdkVersion` to `31`, `targetSdkVersion` to `31` and `Java` version to `11`. ([#16941](https://github.com/expo/expo/pull/16941) by [@bbarthec](https://github.com/bbarthec))
|
|
35
|
+
|
|
36
|
+
## 2.1.1 - 2022-02-01
|
|
14
37
|
|
|
15
38
|
### 🐛 Bug fixes
|
|
16
39
|
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## API documentation
|
|
6
6
|
|
|
7
|
-
Please refer to the [API documentation for the latest stable release](https://docs.expo.
|
|
7
|
+
Please refer to the [API documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/clipboard/).
|
|
8
8
|
|
|
9
9
|
## Installation in managed Expo projects
|
|
10
10
|
|
package/android/build.gradle
CHANGED
|
@@ -3,20 +3,35 @@ apply plugin: 'kotlin-android'
|
|
|
3
3
|
apply plugin: 'maven-publish'
|
|
4
4
|
|
|
5
5
|
group = 'host.exp.exponent'
|
|
6
|
-
version = '
|
|
6
|
+
version = '3.0.0'
|
|
7
7
|
|
|
8
8
|
buildscript {
|
|
9
|
+
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
10
|
+
if (expoModulesCorePlugin.exists()) {
|
|
11
|
+
apply from: expoModulesCorePlugin
|
|
12
|
+
applyKotlinExpoModulesCorePlugin()
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
// Simple helper that allows the root project to override versions declared by this library.
|
|
10
16
|
ext.safeExtGet = { prop, fallback ->
|
|
11
17
|
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
12
18
|
}
|
|
13
19
|
|
|
20
|
+
// Ensures backward compatibility
|
|
21
|
+
ext.getKotlinVersion = {
|
|
22
|
+
if (ext.has("kotlinVersion")) {
|
|
23
|
+
ext.kotlinVersion()
|
|
24
|
+
} else {
|
|
25
|
+
ext.safeExtGet("kotlinVersion", "1.6.10")
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
repositories {
|
|
15
30
|
mavenCentral()
|
|
16
31
|
}
|
|
17
32
|
|
|
18
33
|
dependencies {
|
|
19
|
-
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${
|
|
34
|
+
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
|
|
20
35
|
}
|
|
21
36
|
}
|
|
22
37
|
|
|
@@ -44,18 +59,22 @@ afterEvaluate {
|
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
android {
|
|
47
|
-
compileSdkVersion
|
|
62
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 31)
|
|
48
63
|
|
|
49
64
|
compileOptions {
|
|
50
|
-
sourceCompatibility JavaVersion.
|
|
51
|
-
targetCompatibility JavaVersion.
|
|
65
|
+
sourceCompatibility JavaVersion.VERSION_11
|
|
66
|
+
targetCompatibility JavaVersion.VERSION_11
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
kotlinOptions {
|
|
70
|
+
jvmTarget = JavaVersion.VERSION_11.majorVersion
|
|
52
71
|
}
|
|
53
72
|
|
|
54
73
|
defaultConfig {
|
|
55
|
-
minSdkVersion
|
|
56
|
-
targetSdkVersion
|
|
74
|
+
minSdkVersion safeExtGet("minSdkVersion", 21)
|
|
75
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 31)
|
|
57
76
|
versionCode 3
|
|
58
|
-
versionName '
|
|
77
|
+
versionName '3.0.0'
|
|
59
78
|
}
|
|
60
79
|
lintOptions {
|
|
61
80
|
abortOnError false
|
|
@@ -68,10 +87,12 @@ repositories {
|
|
|
68
87
|
|
|
69
88
|
dependencies {
|
|
70
89
|
implementation project(':expo-modules-core')
|
|
71
|
-
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${
|
|
90
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
|
|
91
|
+
implementation "androidx.core:core-ktx:1.6.0"
|
|
92
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1")
|
|
93
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
|
|
72
94
|
|
|
73
|
-
if (project.findProject(':
|
|
74
|
-
testImplementation project(':
|
|
95
|
+
if (project.findProject(':expo-modules-test-core')) {
|
|
96
|
+
testImplementation project(':expo-modules-test-core')
|
|
75
97
|
}
|
|
76
|
-
testImplementation "org.robolectric:robolectric:4.3.1"
|
|
77
98
|
}
|
|
@@ -1,2 +1,13 @@
|
|
|
1
|
-
<manifest package="expo.modules.clipboard"
|
|
1
|
+
<manifest package="expo.modules.clipboard"
|
|
2
|
+
xmlns:android="http://schemas.android.com/apk/res/android">
|
|
3
|
+
<application>
|
|
4
|
+
<provider
|
|
5
|
+
android:name=".ClipboardFileProvider"
|
|
6
|
+
android:authorities="${applicationId}.ClipboardFileProvider"
|
|
7
|
+
android:exported="true">
|
|
8
|
+
<meta-data
|
|
9
|
+
android:name="expo.modules.clipboard.CLIPBOARD_FILE_PROVIDER_PATHS"
|
|
10
|
+
android:resource="@xml/clipboard_provider_paths"/>
|
|
11
|
+
</provider>
|
|
12
|
+
</application>
|
|
2
13
|
</manifest>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package expo.modules.clipboard
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.exception.CodedException
|
|
4
|
+
|
|
5
|
+
internal class ClipboardUnavailableException :
|
|
6
|
+
CodedException("'CLIPBOARD_SERVICE' is unavailable on this device", null)
|
|
7
|
+
|
|
8
|
+
internal class NoPermissionException(cause: SecurityException?) :
|
|
9
|
+
CodedException("App has no permission to read this clipboard item", cause)
|
|
10
|
+
|
|
11
|
+
internal class PasteFailureException(cause: Throwable?, kind: String = "item") :
|
|
12
|
+
CodedException("Failed to get $kind from clipboard", cause)
|
|
13
|
+
|
|
14
|
+
internal class CopyFailureException(cause: Throwable?, kind: String = "item") :
|
|
15
|
+
CodedException("Failed to save $kind into clipboard", cause)
|
|
16
|
+
|
|
17
|
+
internal class InvalidImageException(image: String, cause: Throwable?) :
|
|
18
|
+
CodedException(
|
|
19
|
+
"Invalid base64 image: ${
|
|
20
|
+
image.run { substring(0, minOf(length, 32)) + if (length > 32) "..." else ""}
|
|
21
|
+
}",
|
|
22
|
+
cause
|
|
23
|
+
)
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
package expo.modules.clipboard
|
|
2
|
+
|
|
3
|
+
import android.content.ContentProvider
|
|
4
|
+
import android.content.ContentValues
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.pm.PackageManager
|
|
7
|
+
import android.content.pm.ProviderInfo
|
|
8
|
+
import android.database.Cursor
|
|
9
|
+
import android.database.MatrixCursor
|
|
10
|
+
import android.net.Uri
|
|
11
|
+
import android.os.Build
|
|
12
|
+
import android.os.Environment
|
|
13
|
+
import android.os.ParcelFileDescriptor
|
|
14
|
+
import android.provider.OpenableColumns
|
|
15
|
+
import android.text.TextUtils
|
|
16
|
+
import android.webkit.MimeTypeMap
|
|
17
|
+
import androidx.annotation.RequiresApi
|
|
18
|
+
import androidx.core.content.FileProvider
|
|
19
|
+
import org.xmlpull.v1.XmlPullParser.END_DOCUMENT
|
|
20
|
+
import org.xmlpull.v1.XmlPullParser.START_TAG
|
|
21
|
+
import org.xmlpull.v1.XmlPullParserException
|
|
22
|
+
import java.io.File
|
|
23
|
+
import java.io.FileNotFoundException
|
|
24
|
+
import java.io.IOException
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* This is a modified version of [FileProvider]
|
|
28
|
+
* that facilitates exposing files associated with an app by creating
|
|
29
|
+
* a `content://` uri without using Androids URI permission mechanism.
|
|
30
|
+
* In contrast to [FileProvider], this provider _must_ be exported.
|
|
31
|
+
*
|
|
32
|
+
* The difference is that [FileProvider] forbids provider to be _exported_
|
|
33
|
+
* which means it cannot easily grant access to any app installed
|
|
34
|
+
* on the device. This becomes even more problematic with API 31, when
|
|
35
|
+
* [PackageManager.getInstalledApplications] doesn't return all
|
|
36
|
+
* installed apps, so we cannot iterate and use [Context.grantUriPermission]
|
|
37
|
+
* easily
|
|
38
|
+
*
|
|
39
|
+
* For usage details, see [FileProvider] documentation
|
|
40
|
+
*/
|
|
41
|
+
@RequiresApi(Build.VERSION_CODES.KITKAT)
|
|
42
|
+
class ClipboardFileProvider : ContentProvider() {
|
|
43
|
+
private val defaultProjectionColumns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
|
44
|
+
|
|
45
|
+
private lateinit var strategy: PathStrategy
|
|
46
|
+
|
|
47
|
+
override fun onCreate() = true
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* After the [ClipboardFileProvider] is instantiated, this method is called to provide the system with
|
|
51
|
+
* information about the provider.
|
|
52
|
+
*
|
|
53
|
+
* @param context A [Context] for the current component.
|
|
54
|
+
* @param info A [ProviderInfo] for the new provider.
|
|
55
|
+
*/
|
|
56
|
+
override fun attachInfo(context: Context, info: ProviderInfo) {
|
|
57
|
+
super.attachInfo(context, info)
|
|
58
|
+
|
|
59
|
+
if (!info.exported) {
|
|
60
|
+
throw AssertionError("ClipboardFileProvider must be exported")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
strategy = getPathStrategy(context, info.authority)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns the MIME type of a content URI returned by
|
|
68
|
+
* [getUriForFile()][getUriForFile].
|
|
69
|
+
*
|
|
70
|
+
* @param uri A content URI returned by
|
|
71
|
+
* [getUriForFile()][getUriForFile].
|
|
72
|
+
* @return If the associated file has an extension, the MIME type associated with that
|
|
73
|
+
* extension; otherwise `application/octet-stream`.
|
|
74
|
+
*/
|
|
75
|
+
override fun getType(uri: Uri): String {
|
|
76
|
+
val file: File = strategy.getFileForUri(uri)
|
|
77
|
+
val lastDot = file.name.lastIndexOf('.')
|
|
78
|
+
if (lastDot >= 0) {
|
|
79
|
+
val extension = file.name.substring(lastDot + 1)
|
|
80
|
+
MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)?.let {
|
|
81
|
+
return it
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return "application/octet-stream"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? {
|
|
88
|
+
val projection = projection ?: defaultProjectionColumns
|
|
89
|
+
val file: File = strategy.getFileForUri(uri)
|
|
90
|
+
var columns = arrayOfNulls<String>(projection.size)
|
|
91
|
+
var values = arrayOfNulls<Any>(projection.size)
|
|
92
|
+
var i = 0
|
|
93
|
+
for (column in projection) {
|
|
94
|
+
when (column) {
|
|
95
|
+
OpenableColumns.DISPLAY_NAME -> {
|
|
96
|
+
columns[i] = OpenableColumns.DISPLAY_NAME
|
|
97
|
+
values[i++] = file.name
|
|
98
|
+
}
|
|
99
|
+
OpenableColumns.SIZE -> {
|
|
100
|
+
columns[i] = OpenableColumns.SIZE
|
|
101
|
+
values[i++] = file.length()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
columns = columns.copyOf(i)
|
|
106
|
+
values = values.copyOf(i)
|
|
107
|
+
return MatrixCursor(columns, 1).apply {
|
|
108
|
+
addRow(values)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
override fun insert(uri: Uri, values: ContentValues?): Uri =
|
|
113
|
+
throw UnsupportedOperationException("This is a read-only provider")
|
|
114
|
+
|
|
115
|
+
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int =
|
|
116
|
+
throw UnsupportedOperationException("This is a read-only provider")
|
|
117
|
+
|
|
118
|
+
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int =
|
|
119
|
+
throw UnsupportedOperationException("This is a read-only provider")
|
|
120
|
+
|
|
121
|
+
@Throws(FileNotFoundException::class)
|
|
122
|
+
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
|
123
|
+
require("r" == mode) { "mode must be \"r\"" }
|
|
124
|
+
val file = strategy.getFileForUri(uri)
|
|
125
|
+
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
companion object {
|
|
129
|
+
private const val META_DATA_FILE_PROVIDER_PATHS =
|
|
130
|
+
"expo.modules.clipboard.CLIPBOARD_FILE_PROVIDER_PATHS"
|
|
131
|
+
|
|
132
|
+
private const val TAG_ROOT_PATH = "root-path"
|
|
133
|
+
private const val TAG_FILES_PATH = "files-path"
|
|
134
|
+
private const val TAG_CACHE_PATH = "cache-path"
|
|
135
|
+
private const val TAG_EXTERNAL = "external-path"
|
|
136
|
+
private const val TAG_EXTERNAL_FILES = "external-files-path"
|
|
137
|
+
private const val TAG_EXTERNAL_CACHE = "external-cache-path"
|
|
138
|
+
|
|
139
|
+
private const val ATTR_NAME = "name"
|
|
140
|
+
private const val ATTR_PATH = "path"
|
|
141
|
+
|
|
142
|
+
private val DEVICE_ROOT = File("/")
|
|
143
|
+
|
|
144
|
+
private val cache: HashMap<String, PathStrategy> = HashMap()
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Return a content URI for a given [File]. A PublicFileProvider can only return a
|
|
148
|
+
* `content` [Uri] for file paths defined in their `<paths>`
|
|
149
|
+
* meta-data element. See the Class Overview for more information.
|
|
150
|
+
*
|
|
151
|
+
* @param context A [Context] for the current component.
|
|
152
|
+
* @param authority The authority of a [ClipboardFileProvider] defined in a
|
|
153
|
+
* `<provider>` element in your app's manifest.
|
|
154
|
+
* @param file A [File] pointing to the filename for which you want a
|
|
155
|
+
* `content` [Uri].
|
|
156
|
+
* @return A content URI for the file.
|
|
157
|
+
* @throws IllegalArgumentException When the given [File] is outside
|
|
158
|
+
* the paths supported by the provider.
|
|
159
|
+
*/
|
|
160
|
+
fun getUriForFile(context: Context, authority: String, file: File): Uri? {
|
|
161
|
+
val strategy = getPathStrategy(context, authority)
|
|
162
|
+
return strategy.getUriForFile(file)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Return {@link PathStrategy} for given authority, either by parsing or
|
|
167
|
+
* returning from cache.
|
|
168
|
+
*/
|
|
169
|
+
internal fun getPathStrategy(context: Context, authority: String): PathStrategy {
|
|
170
|
+
var pathStrategy: PathStrategy
|
|
171
|
+
synchronized(cache) {
|
|
172
|
+
pathStrategy = cache[authority] ?: run {
|
|
173
|
+
try {
|
|
174
|
+
pathStrategy = parsePathStrategy(context, authority)
|
|
175
|
+
} catch (e: IOException) {
|
|
176
|
+
throw IllegalArgumentException(
|
|
177
|
+
"Failed to parse $META_DATA_FILE_PROVIDER_PATHS meta-data", e
|
|
178
|
+
)
|
|
179
|
+
} catch (e: XmlPullParserException) {
|
|
180
|
+
throw IllegalArgumentException(
|
|
181
|
+
"Failed to parse $META_DATA_FILE_PROVIDER_PATHS meta-data", e
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
cache[authority] = pathStrategy
|
|
185
|
+
pathStrategy
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return pathStrategy
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Parse and return [PathStrategy] for given authority as defined in
|
|
193
|
+
* [META_DATA_FILE_PROVIDER_PATHS] `<meta-data>`.
|
|
194
|
+
*
|
|
195
|
+
* @see .getPathStrategy
|
|
196
|
+
*/
|
|
197
|
+
@Throws(IOException::class, XmlPullParserException::class)
|
|
198
|
+
private fun parsePathStrategy(context: Context, authority: String): PathStrategy {
|
|
199
|
+
val pathStrategy = SimplePathStrategy(authority)
|
|
200
|
+
val packageManager = context.packageManager
|
|
201
|
+
val info = packageManager.resolveContentProvider(authority, PackageManager.GET_META_DATA)
|
|
202
|
+
?: throw IllegalArgumentException("Couldn't find meta-data for provider with authority $authority")
|
|
203
|
+
val parser = info.loadXmlMetaData(packageManager, META_DATA_FILE_PROVIDER_PATHS)
|
|
204
|
+
?: throw IllegalArgumentException("Missing $META_DATA_FILE_PROVIDER_PATHS meta-data")
|
|
205
|
+
var type: Int
|
|
206
|
+
while (parser.next().also { type = it } != END_DOCUMENT) {
|
|
207
|
+
if (type != START_TAG) continue
|
|
208
|
+
|
|
209
|
+
val tag = parser.name
|
|
210
|
+
val target: File? = targetFileFromTag(tag, context)
|
|
211
|
+
target?.let {
|
|
212
|
+
val name = parser.getAttributeValue(null, ATTR_NAME)
|
|
213
|
+
val path = parser.getAttributeValue(null, ATTR_PATH)
|
|
214
|
+
pathStrategy.addRoot(name, buildPath(it, path))
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return pathStrategy
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private fun targetFileFromTag(tag: String, context: Context): File? = when (tag) {
|
|
221
|
+
TAG_ROOT_PATH -> DEVICE_ROOT
|
|
222
|
+
TAG_FILES_PATH -> context.filesDir
|
|
223
|
+
TAG_CACHE_PATH -> context.cacheDir
|
|
224
|
+
TAG_EXTERNAL -> Environment.getExternalStorageDirectory()
|
|
225
|
+
TAG_EXTERNAL_FILES -> {
|
|
226
|
+
val externalFilesDirs: Array<File> = context.getExternalFilesDirs(null)
|
|
227
|
+
externalFilesDirs.takeIf { it.isNotEmpty() }?.let { it[0] }
|
|
228
|
+
}
|
|
229
|
+
TAG_EXTERNAL_CACHE -> {
|
|
230
|
+
val externalCacheDirs: Array<File> = context.externalCacheDirs
|
|
231
|
+
externalCacheDirs.takeIf { it.isNotEmpty() }?.let { it[0] }
|
|
232
|
+
}
|
|
233
|
+
else -> null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private fun buildPath(base: File, vararg segments: String?): File {
|
|
237
|
+
var cur = base
|
|
238
|
+
for (segment in segments) {
|
|
239
|
+
if (segment != null) {
|
|
240
|
+
cur = File(cur, segment)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return cur
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Strategy for mapping between [File] and [Uri].
|
|
249
|
+
*
|
|
250
|
+
*
|
|
251
|
+
* Strategies must be symmetric so that mapping a [File] to a
|
|
252
|
+
* [Uri] and then back to a [File] points at the original
|
|
253
|
+
* target.
|
|
254
|
+
*
|
|
255
|
+
*
|
|
256
|
+
* Strategies must remain consistent across app launches, and not rely on
|
|
257
|
+
* dynamic state. This ensures that any generated [Uri] can still be
|
|
258
|
+
* resolved if your process is killed and later restarted.
|
|
259
|
+
*
|
|
260
|
+
* @see SimplePathStrategy
|
|
261
|
+
*/
|
|
262
|
+
internal interface PathStrategy {
|
|
263
|
+
/**
|
|
264
|
+
* Return a [Uri] that represents the given [File].
|
|
265
|
+
*/
|
|
266
|
+
fun getUriForFile(file: File): Uri?
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Return a [File] that represents the given [Uri].
|
|
270
|
+
*/
|
|
271
|
+
fun getFileForUri(uri: Uri): File
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Strategy that provides access to files living under a narrow whitelist of
|
|
276
|
+
* filesystem roots. It will throw [SecurityException] if callers try
|
|
277
|
+
* accessing files outside the configured roots.
|
|
278
|
+
*
|
|
279
|
+
*
|
|
280
|
+
* For example, if configured with
|
|
281
|
+
* `addRoot("myfiles", context.getFilesDir())`, then
|
|
282
|
+
* `context.getFileStreamPath("foo.txt")` would map to
|
|
283
|
+
* `content://myauthority/myfiles/foo.txt`.
|
|
284
|
+
*/
|
|
285
|
+
internal class SimplePathStrategy(private val authority: String) : PathStrategy {
|
|
286
|
+
private val roots: HashMap<String, File> = HashMap()
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Add a mapping from a name to a filesystem root. The provider only offers
|
|
290
|
+
* access to files that live under configured roots.
|
|
291
|
+
*/
|
|
292
|
+
fun addRoot(name: String?, root: File) {
|
|
293
|
+
require(name != null && !TextUtils.isEmpty(name)) { "Name must not be empty" }
|
|
294
|
+
val newRoot = try {
|
|
295
|
+
// Resolve to canonical path to keep path checking fast
|
|
296
|
+
root.canonicalFile
|
|
297
|
+
} catch (e: IOException) {
|
|
298
|
+
throw IllegalArgumentException("Failed to resolve canonical path for $root", e)
|
|
299
|
+
}
|
|
300
|
+
roots[name] = newRoot
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
override fun getUriForFile(file: File): Uri? {
|
|
304
|
+
var path: String = try {
|
|
305
|
+
file.canonicalPath
|
|
306
|
+
} catch (e: IOException) {
|
|
307
|
+
throw java.lang.IllegalArgumentException("Failed to resolve canonical path for $file")
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Find the most-specific root path
|
|
311
|
+
var mostSpecific: Map.Entry<String, File>? = null
|
|
312
|
+
for (root in roots.entries) {
|
|
313
|
+
val rootPath = root.value.path
|
|
314
|
+
if (path.startsWith(rootPath) && (
|
|
315
|
+
mostSpecific == null ||
|
|
316
|
+
rootPath.length > mostSpecific.value.path.length
|
|
317
|
+
)
|
|
318
|
+
) {
|
|
319
|
+
mostSpecific = root
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
requireNotNull(mostSpecific) { "Failed to find configured root that contains $path" }
|
|
323
|
+
|
|
324
|
+
// Start at first char of path under root
|
|
325
|
+
val rootPath = mostSpecific.value.path
|
|
326
|
+
path = if (rootPath.endsWith("/")) {
|
|
327
|
+
path.substring(rootPath.length)
|
|
328
|
+
} else {
|
|
329
|
+
path.substring(rootPath.length + 1)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Encode the tag and path separately
|
|
333
|
+
path = Uri.encode(mostSpecific.key) + '/' + Uri.encode(path, "/")
|
|
334
|
+
return Uri.Builder()
|
|
335
|
+
.scheme("content")
|
|
336
|
+
.authority(authority)
|
|
337
|
+
.encodedPath(path)
|
|
338
|
+
.build()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
override fun getFileForUri(uri: Uri): File {
|
|
342
|
+
var path = uri.encodedPath!!
|
|
343
|
+
val splitIndex = path.indexOf('/', 1)
|
|
344
|
+
val tag = Uri.decode(path.substring(1, splitIndex))
|
|
345
|
+
path = Uri.decode(path.substring(splitIndex + 1))
|
|
346
|
+
val root = roots[tag]
|
|
347
|
+
?: throw java.lang.IllegalArgumentException("Unable to find configured root for $uri")
|
|
348
|
+
var file = File(root, path)
|
|
349
|
+
file = try {
|
|
350
|
+
file.canonicalFile
|
|
351
|
+
} catch (e: IOException) {
|
|
352
|
+
throw java.lang.IllegalArgumentException("Failed to resolve canonical path for $file")
|
|
353
|
+
}
|
|
354
|
+
if (!file.path.startsWith(root.path)) {
|
|
355
|
+
throw SecurityException("Resolved path jumped beyond configured root")
|
|
356
|
+
}
|
|
357
|
+
return file
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
package expo.modules.clipboard
|
|
2
|
+
|
|
3
|
+
import android.content.ClipData
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.graphics.Bitmap
|
|
6
|
+
import android.graphics.BitmapFactory
|
|
7
|
+
import android.graphics.ImageDecoder
|
|
8
|
+
import android.net.Uri
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.provider.MediaStore
|
|
11
|
+
import android.util.Base64
|
|
12
|
+
import androidx.core.os.bundleOf
|
|
13
|
+
import kotlinx.coroutines.Dispatchers
|
|
14
|
+
import kotlinx.coroutines.runInterruptible
|
|
15
|
+
import kotlinx.coroutines.yield
|
|
16
|
+
import java.io.BufferedOutputStream
|
|
17
|
+
import java.io.ByteArrayOutputStream
|
|
18
|
+
import java.io.File
|
|
19
|
+
import java.io.FileOutputStream
|
|
20
|
+
import java.io.IOException
|
|
21
|
+
import java.lang.StringBuilder
|
|
22
|
+
import java.util.*
|
|
23
|
+
|
|
24
|
+
// region Structs and interfaces
|
|
25
|
+
data class ImageResult(
|
|
26
|
+
val base64Image: String,
|
|
27
|
+
val width: Int,
|
|
28
|
+
val height: Int
|
|
29
|
+
) {
|
|
30
|
+
fun toBundle() = bundleOf(
|
|
31
|
+
"data" to base64Image,
|
|
32
|
+
"size" to bundleOf(
|
|
33
|
+
"width" to width,
|
|
34
|
+
"height" to height
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
// endregion
|
|
39
|
+
|
|
40
|
+
// region Module functions
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Gets the [imageUri] and returns the [ImageResult] object containing base64 encoded image
|
|
44
|
+
* and its metadata
|
|
45
|
+
*
|
|
46
|
+
* @param context
|
|
47
|
+
* @param imageUri `content://` uri of the image to be resolved
|
|
48
|
+
*
|
|
49
|
+
* @throws IOException
|
|
50
|
+
* @throws SecurityException when app has no permission to access the content uri
|
|
51
|
+
*/
|
|
52
|
+
internal suspend fun imageFromContentUri(
|
|
53
|
+
context: Context,
|
|
54
|
+
imageUri: Uri,
|
|
55
|
+
options: GetImageOptions
|
|
56
|
+
): ImageResult {
|
|
57
|
+
// 1. Retrieve bitmap from URI
|
|
58
|
+
val bitmap = bitmapFromContentUriAsync(context, imageUri)
|
|
59
|
+
|
|
60
|
+
// 2. Compress it to target format
|
|
61
|
+
val format = options.imageFormat
|
|
62
|
+
val quality = (options.jpegQuality * 100).toInt()
|
|
63
|
+
val outputStream = ByteArrayOutputStream().also {
|
|
64
|
+
bitmap.compress(format.compressFormat, quality, it)
|
|
65
|
+
}
|
|
66
|
+
yield()
|
|
67
|
+
|
|
68
|
+
// 3. Convert to base64
|
|
69
|
+
val byteArray = outputStream.toByteArray()
|
|
70
|
+
val encodedString = Base64.encodeToString(byteArray, Base64.DEFAULT)
|
|
71
|
+
val builder = StringBuilder("data:${format.mimeType};base64,").append(encodedString)
|
|
72
|
+
|
|
73
|
+
return ImageResult(
|
|
74
|
+
base64Image = builder.toString(),
|
|
75
|
+
width = bitmap.width,
|
|
76
|
+
height = bitmap.height
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Gets base64-encoded image data and prepares it to be accessible by other applications
|
|
82
|
+
* when pasting from clipboard
|
|
83
|
+
*
|
|
84
|
+
* Saves the image to the [clipboardCacheDir] directory, then uses [ClipboardFileProvider]
|
|
85
|
+
* to create a `content://` URI which then is packed to the [ClipData] object.
|
|
86
|
+
*
|
|
87
|
+
* @param context
|
|
88
|
+
* @param base64Image base64-encoded JPEG image data. Should not be prefixed
|
|
89
|
+
* @param clipboardCacheDir directory where the copied image is stored, must be accessible by
|
|
90
|
+
* the [ClipboardFileProvider]
|
|
91
|
+
* @return clip data ready to be shared by the [android.content.ClipboardManager]
|
|
92
|
+
*/
|
|
93
|
+
internal suspend fun clipDataFromBase64Image(
|
|
94
|
+
context: Context,
|
|
95
|
+
base64Image: String,
|
|
96
|
+
clipboardCacheDir: File,
|
|
97
|
+
): ClipData {
|
|
98
|
+
// 1. Get bitmap from base64 string
|
|
99
|
+
val bitmap = bitmapFromBase64String(base64Image)
|
|
100
|
+
|
|
101
|
+
// 2. Create file in cache dir, it will be overwritten if already exists
|
|
102
|
+
val file = File(clipboardCacheDir, "copied_image.jpeg").also {
|
|
103
|
+
it.ensureExists()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 3. Write bitmap to the file
|
|
107
|
+
val fileStream = runInterruptible { FileOutputStream(file, false) }
|
|
108
|
+
BufferedOutputStream(fileStream).use { outputStream ->
|
|
109
|
+
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
|
|
110
|
+
runInterruptible { outputStream.flush() }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 4. Get content:// URI to the image file and put it to the clipboard data
|
|
114
|
+
val imageUri = ClipboardFileProvider.getUriForFile(
|
|
115
|
+
context,
|
|
116
|
+
context.applicationInfo.packageName + ".ClipboardFileProvider",
|
|
117
|
+
file
|
|
118
|
+
)
|
|
119
|
+
return ClipData.newUri(context.contentResolver, "image", imageUri)
|
|
120
|
+
}
|
|
121
|
+
// endregion
|
|
122
|
+
|
|
123
|
+
// region Utility functions
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Retrieves [Bitmap] from `content://` image uri
|
|
127
|
+
* @throws SecurityException when app has no permission to access the content uri
|
|
128
|
+
* @throws IOException
|
|
129
|
+
*/
|
|
130
|
+
internal suspend fun bitmapFromContentUriAsync(context: Context, imageUri: Uri): Bitmap =
|
|
131
|
+
runInterruptible(Dispatchers.IO) {
|
|
132
|
+
val contentResolver = context.contentResolver
|
|
133
|
+
when {
|
|
134
|
+
Build.VERSION.SDK_INT < 28 -> MediaStore.Images.Media.getBitmap(
|
|
135
|
+
contentResolver,
|
|
136
|
+
imageUri
|
|
137
|
+
)
|
|
138
|
+
else -> {
|
|
139
|
+
val source = ImageDecoder.createSource(contentResolver, imageUri)
|
|
140
|
+
ImageDecoder.decodeBitmap(source)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
internal fun bitmapFromBase64String(base64Image: String): Bitmap = try {
|
|
146
|
+
val byteArray = Base64.decode(base64Image, Base64.DEFAULT)
|
|
147
|
+
BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
|
|
148
|
+
?: throw RuntimeException("Failed to convert base64 into Bitmap")
|
|
149
|
+
} catch (e: RuntimeException) {
|
|
150
|
+
// Base64.decode throws IllegalArgumentException on invalid data, but
|
|
151
|
+
// BitmapFactory.decodeByteArray returns null if data cannot be converted
|
|
152
|
+
// so we aggregate both errors here
|
|
153
|
+
throw InvalidImageException(base64Image, e)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Creates the file and all its parent directories if they don't exist already.
|
|
158
|
+
*/
|
|
159
|
+
private suspend fun File.ensureExists() = runInterruptible(Dispatchers.IO) {
|
|
160
|
+
parentFile?.mkdirs()
|
|
161
|
+
createNewFile()
|
|
162
|
+
}
|
|
163
|
+
// endregion
|