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.
Files changed (55) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +1 -1
  3. package/android/build.gradle +33 -12
  4. package/android/src/main/AndroidManifest.xml +12 -1
  5. package/android/src/main/java/expo/modules/clipboard/ClipboardExceptions.kt +23 -0
  6. package/android/src/main/java/expo/modules/clipboard/ClipboardFileProvider.kt +360 -0
  7. package/android/src/main/java/expo/modules/clipboard/ClipboardImage.kt +163 -0
  8. package/android/src/main/java/expo/modules/clipboard/ClipboardModule.kt +246 -24
  9. package/android/src/main/java/expo/modules/clipboard/ClipboardOptions.kt +44 -0
  10. package/android/src/main/res/xml/clipboard_provider_paths.xml +5 -0
  11. package/build/Clipboard.d.ts +98 -7
  12. package/build/Clipboard.d.ts.map +1 -0
  13. package/build/Clipboard.js +150 -11
  14. package/build/Clipboard.js.map +1 -1
  15. package/build/Clipboard.types.d.ts +74 -0
  16. package/build/Clipboard.types.d.ts.map +1 -0
  17. package/build/Clipboard.types.js +22 -0
  18. package/build/Clipboard.types.js.map +1 -0
  19. package/build/ExpoClipboard.d.ts +1 -0
  20. package/build/ExpoClipboard.d.ts.map +1 -0
  21. package/build/ExpoClipboard.web.d.ts +2 -8
  22. package/build/ExpoClipboard.web.d.ts.map +1 -0
  23. package/build/ExpoClipboard.web.js +1 -38
  24. package/build/ExpoClipboard.web.js.map +1 -1
  25. package/build/web/ClipboardModule.d.ts +15 -0
  26. package/build/web/ClipboardModule.d.ts.map +1 -0
  27. package/build/web/ClipboardModule.js +177 -0
  28. package/build/web/ClipboardModule.js.map +1 -0
  29. package/build/web/Exceptions.d.ts +14 -0
  30. package/build/web/Exceptions.d.ts.map +1 -0
  31. package/build/web/Exceptions.js +22 -0
  32. package/build/web/Exceptions.js.map +1 -0
  33. package/build/web/Utils.d.ts +18 -0
  34. package/build/web/Utils.d.ts.map +1 -0
  35. package/build/web/Utils.js +79 -0
  36. package/build/web/Utils.js.map +1 -0
  37. package/expo-module.config.json +3 -0
  38. package/ios/ClipboardExceptions.swift +15 -0
  39. package/ios/ClipboardModule.swift +136 -0
  40. package/ios/ClipboardOptions.swift +40 -0
  41. package/ios/{EXClipboard.podspec → ExpoClipboard.podspec} +3 -3
  42. package/ios/NSAttributedString+utilities.swift +34 -0
  43. package/ios/UIPasteboard+html.swift +47 -0
  44. package/package.json +2 -2
  45. package/src/Clipboard.ts +176 -12
  46. package/src/Clipboard.types.ts +80 -0
  47. package/src/ExpoClipboard.web.ts +1 -35
  48. package/src/web/ClipboardModule.ts +198 -0
  49. package/src/web/Exceptions.ts +25 -0
  50. package/src/web/Utils.ts +87 -0
  51. package/android/src/main/java/expo/modules/clipboard/ClipboardEventEmitter.kt +0 -49
  52. package/android/src/main/java/expo/modules/clipboard/ClipboardPackage.kt +0 -12
  53. package/ios/EXClipboard/ClipboardModule.swift +0 -42
  54. package/ios/EXClipboard/EXClipboardModule.h +0 -8
  55. package/ios/EXClipboard/EXClipboardModule.m +0 -88
package/CHANGELOG.md CHANGED
@@ -10,7 +10,30 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
- ## 2.1.1 — 2022-02-01
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.io/versions/latest/sdk/clipboard/).
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
 
@@ -3,20 +3,35 @@ apply plugin: 'kotlin-android'
3
3
  apply plugin: 'maven-publish'
4
4
 
5
5
  group = 'host.exp.exponent'
6
- version = '2.1.1'
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:${safeExtGet('kotlinVersion', '1.4.21')}")
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 rootProject.ext.compileSdkVersion
62
+ compileSdkVersion safeExtGet("compileSdkVersion", 31)
48
63
 
49
64
  compileOptions {
50
- sourceCompatibility JavaVersion.VERSION_1_8
51
- targetCompatibility JavaVersion.VERSION_1_8
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 rootProject.ext.minSdkVersion
56
- targetSdkVersion rootProject.ext.targetSdkVersion
74
+ minSdkVersion safeExtGet("minSdkVersion", 21)
75
+ targetSdkVersion safeExtGet("targetSdkVersion", 31)
57
76
  versionCode 3
58
- versionName '2.1.1'
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:${safeExtGet('kotlinVersion', '1.4.21')}"
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(':unimodules-test-core')) {
74
- testImplementation project(':unimodules-test-core')
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