expo-clipboard 2.0.3 → 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 +31 -4
- package/README.md +1 -1
- package/android/build.gradle +47 -28
- 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 +10 -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} +10 -6
- package/ios/NSAttributedString+utilities.swift +34 -0
- package/ios/UIPasteboard+html.swift +47 -0
- package/package.json +6 -5
- 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/EXClipboardModule.h +0 -8
- package/ios/EXClipboard/EXClipboardModule.m +0 -88
- package/unimodule.json +0 -4
package/CHANGELOG.md
CHANGED
|
@@ -10,13 +10,40 @@
|
|
|
10
10
|
|
|
11
11
|
### 💡 Others
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## 3.0.0 — 2022-04-18
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
### 🛠 Breaking changes
|
|
16
16
|
|
|
17
|
-
|
|
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
18
|
|
|
19
|
-
|
|
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
|
|
37
|
+
|
|
38
|
+
### 🐛 Bug fixes
|
|
39
|
+
|
|
40
|
+
- Fix `Plugin with id 'maven' not found` build error from Android Gradle 7. ([#16080](https://github.com/expo/expo/pull/16080) by [@kudo](https://github.com/kudo))
|
|
41
|
+
|
|
42
|
+
## 2.1.0 — 2021-12-03
|
|
43
|
+
|
|
44
|
+
### 🎉 New features
|
|
45
|
+
|
|
46
|
+
- Native module on iOS is now written in Swift using [Sweet API](https://blog.expo.dev/a-peek-into-the-upcoming-sweet-expo-module-api-6de6b9aca492). ([#14959](https://github.com/expo/expo/pull/14959) by [@tsapeta](https://github.com/tsapeta))
|
|
20
47
|
|
|
21
48
|
## 2.0.1 — 2021-10-01
|
|
22
49
|
|
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
|
@@ -1,63 +1,80 @@
|
|
|
1
1
|
apply plugin: 'com.android.library'
|
|
2
2
|
apply plugin: 'kotlin-android'
|
|
3
|
-
apply plugin: 'maven'
|
|
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
|
|
|
23
|
-
// Upload android library to maven with javadoc and android sources
|
|
24
|
-
configurations {
|
|
25
|
-
deployerJars
|
|
26
|
-
}
|
|
27
|
-
|
|
28
38
|
// Creating sources with comments
|
|
29
39
|
task androidSourcesJar(type: Jar) {
|
|
30
40
|
classifier = 'sources'
|
|
31
41
|
from android.sourceSets.main.java.srcDirs
|
|
32
42
|
}
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
afterEvaluate {
|
|
45
|
+
publishing {
|
|
46
|
+
publications {
|
|
47
|
+
release(MavenPublication) {
|
|
48
|
+
from components.release
|
|
49
|
+
// Add additional sourcesJar to artifacts
|
|
50
|
+
artifact(androidSourcesJar)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
repositories {
|
|
54
|
+
maven {
|
|
55
|
+
url = mavenLocal().url
|
|
56
|
+
}
|
|
44
57
|
}
|
|
45
58
|
}
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
android {
|
|
49
|
-
compileSdkVersion
|
|
62
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 31)
|
|
50
63
|
|
|
51
64
|
compileOptions {
|
|
52
|
-
sourceCompatibility JavaVersion.
|
|
53
|
-
targetCompatibility JavaVersion.
|
|
65
|
+
sourceCompatibility JavaVersion.VERSION_11
|
|
66
|
+
targetCompatibility JavaVersion.VERSION_11
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
kotlinOptions {
|
|
70
|
+
jvmTarget = JavaVersion.VERSION_11.majorVersion
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
defaultConfig {
|
|
57
|
-
minSdkVersion
|
|
58
|
-
targetSdkVersion
|
|
74
|
+
minSdkVersion safeExtGet("minSdkVersion", 21)
|
|
75
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 31)
|
|
59
76
|
versionCode 3
|
|
60
|
-
versionName '
|
|
77
|
+
versionName '3.0.0'
|
|
61
78
|
}
|
|
62
79
|
lintOptions {
|
|
63
80
|
abortOnError false
|
|
@@ -70,10 +87,12 @@ repositories {
|
|
|
70
87
|
|
|
71
88
|
dependencies {
|
|
72
89
|
implementation project(':expo-modules-core')
|
|
73
|
-
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")
|
|
74
94
|
|
|
75
|
-
if (project.findProject(':
|
|
76
|
-
testImplementation project(':
|
|
95
|
+
if (project.findProject(':expo-modules-test-core')) {
|
|
96
|
+
testImplementation project(':expo-modules-test-core')
|
|
77
97
|
}
|
|
78
|
-
testImplementation "org.robolectric:robolectric:4.3.1"
|
|
79
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
|
+
}
|