Skip to content

Commit c8cee7e

Browse files
authored
Merge pull request #1683 from theovilardo/fix/artwork-file-provider-dedicated
Implement `SharedArtworkContentProvider` to centralize and secure alb…
2 parents 48d4f24 + b80469c commit c8cee7e

6 files changed

Lines changed: 282 additions & 9 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,10 @@
242242
android:name="android.support.FILE_PROVIDER_PATHS"
243243
android:resource="@xml/file_paths" />
244244
</provider>
245+
<provider
246+
android:name=".data.provider.SharedArtworkContentProvider"
247+
android:authorities="${applicationId}.artwork"
248+
android:exported="true" />
245249

246250
<!-- Wear OS Data Layer: receives playback/volume commands from the watch -->
247251
<service
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package com.theveloper.pixelplay.data.provider
2+
3+
import android.content.ContentProvider
4+
import android.content.ContentValues
5+
import android.content.Context
6+
import android.content.res.AssetFileDescriptor
7+
import android.database.Cursor
8+
import android.net.Uri
9+
import android.os.ParcelFileDescriptor
10+
import com.theveloper.pixelplay.utils.AlbumArtUtils
11+
import java.io.File
12+
import java.io.FileNotFoundException
13+
14+
class SharedArtworkContentProvider : ContentProvider() {
15+
16+
override fun onCreate(): Boolean = true
17+
18+
override fun query(
19+
uri: Uri,
20+
projection: Array<out String>?,
21+
selection: String?,
22+
selectionArgs: Array<out String>?,
23+
sortOrder: String?
24+
): Cursor? = null
25+
26+
override fun getType(uri: Uri): String? {
27+
val appContext = context?.applicationContext ?: return null
28+
return if (parseSongId(uri, appContext.packageName) != null) {
29+
DEFAULT_CONTENT_TYPE
30+
} else {
31+
null
32+
}
33+
}
34+
35+
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
36+
37+
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
38+
39+
override fun update(
40+
uri: Uri,
41+
values: ContentValues?,
42+
selection: String?,
43+
selectionArgs: Array<out String>?
44+
): Int = 0
45+
46+
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
47+
if (mode != "r") {
48+
throw FileNotFoundException("Shared artwork provider is read-only")
49+
}
50+
51+
val file = resolveArtworkFile(uri)
52+
?: throw FileNotFoundException("No artwork found for uri=$uri")
53+
54+
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
55+
}
56+
57+
override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor {
58+
val fileDescriptor = openFile(uri, mode)
59+
return AssetFileDescriptor(fileDescriptor, 0, AssetFileDescriptor.UNKNOWN_LENGTH)
60+
}
61+
62+
private fun resolveArtworkFile(uri: Uri): File? {
63+
val appContext = context?.applicationContext ?: return null
64+
val songId = parseSongId(uri, appContext.packageName) ?: return null
65+
return AlbumArtUtils.ensureAlbumArtCachedFile(
66+
appContext = appContext,
67+
songId = songId
68+
)?.takeIf { it.exists() && it.isFile && it.canRead() }
69+
}
70+
71+
companion object {
72+
private const val AUTHORITY_SUFFIX = ".artwork"
73+
private const val PATH_SONG = "song"
74+
private const val DEFAULT_CONTENT_TYPE = "image/jpeg"
75+
76+
fun authority(packageName: String): String = packageName + AUTHORITY_SUFFIX
77+
78+
fun buildSongUri(
79+
context: Context,
80+
songId: Long,
81+
cacheBustToken: String? = null
82+
): Uri = buildSongUri(context.packageName, songId, cacheBustToken)
83+
84+
internal fun buildSongUri(
85+
packageName: String,
86+
songId: Long,
87+
cacheBustToken: String? = null
88+
): Uri {
89+
return Uri.parse(buildSongUriString(packageName, songId, cacheBustToken))
90+
}
91+
92+
internal fun buildSongUriString(
93+
packageName: String,
94+
songId: Long,
95+
cacheBustToken: String? = null
96+
): String {
97+
val baseUri = "content://${authority(packageName)}/$PATH_SONG/$songId"
98+
return cacheBustToken
99+
?.takeIf { it.isNotBlank() }
100+
?.let { "$baseUri?t=$it" }
101+
?: baseUri
102+
}
103+
104+
internal fun parseSongId(uri: Uri, packageName: String? = null): Long? {
105+
return parseSongId(uri.toString(), packageName)
106+
}
107+
108+
internal fun parseSongId(uriString: String, packageName: String? = null): Long? {
109+
val expectedPrefix = packageName
110+
?.let(::authority)
111+
?.let { "content://$it/$PATH_SONG/" }
112+
113+
if (expectedPrefix != null && !uriString.startsWith(expectedPrefix)) {
114+
return null
115+
}
116+
117+
val basePrefix = expectedPrefix ?: run {
118+
val authoritySeparator = "://"
119+
val schemeSplit = uriString.indexOf(authoritySeparator)
120+
if (schemeSplit < 0) return null
121+
val pathStart = uriString.indexOf('/', schemeSplit + authoritySeparator.length)
122+
if (pathStart < 0) return null
123+
val pathPrefix = uriString.substring(pathStart)
124+
if (!pathPrefix.startsWith("/$PATH_SONG/")) return null
125+
uriString.substring(0, pathStart) + "/$PATH_SONG/"
126+
}
127+
128+
val songIdSegment = uriString
129+
.removePrefix(basePrefix)
130+
.substringBefore('?')
131+
.substringBefore('/')
132+
133+
if (songIdSegment.isBlank()) {
134+
return null
135+
}
136+
return songIdSegment.toLongOrNull()
137+
}
138+
}
139+
}

app/src/main/java/com/theveloper/pixelplay/utils/LocalArtworkUri.kt

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.net.Uri
55
object LocalArtworkUri {
66
const val SCHEME = "pixelplay_local_art"
77
private const val HOST_SONG = "song"
8+
private const val CACHE_BUST_QUERY = "t"
89

910
fun buildSongUri(songId: Long): String = "$SCHEME://$HOST_SONG/$songId"
1011
fun buildSongUriWithTimestamp(songId: Long): String = buildSongUri(songId) + "?t=${System.currentTimeMillis()}"
@@ -28,13 +29,49 @@ object LocalArtworkUri {
2829
fun looksLikeVolatileArtworkUri(uriString: String?): Boolean {
2930
if (uriString.isNullOrBlank()) return false
3031
val normalized = uriString.lowercase()
31-
return normalized.contains("song_art_") &&
32+
val isLegacyCachedFileUri = normalized.contains("song_art_") &&
3233
(
3334
normalized.startsWith("content://") ||
3435
normalized.startsWith("file://") ||
3536
normalized.startsWith("/") ||
3637
normalized.contains(".provider/")
3738
)
39+
val isSharedArtworkUri = normalized.startsWith("content://") &&
40+
normalized.contains(".artwork/song/")
41+
return isLegacyCachedFileUri || isSharedArtworkUri
42+
}
43+
44+
fun parseSongIdFromVolatileArtworkUri(uriString: String?): Long? {
45+
if (uriString.isNullOrBlank()) return null
46+
if (!looksLikeVolatileArtworkUri(uriString)) return null
47+
48+
val fileName = uriString.substringAfterLast('/').substringBefore('?')
49+
if (!fileName.startsWith("song_art_")) {
50+
return null
51+
}
52+
53+
return fileName
54+
.removePrefix("song_art_")
55+
.substringBefore('_')
56+
.substringBefore('.')
57+
.toLongOrNull()
58+
}
59+
60+
fun extractCacheBustToken(uriString: String?): String? {
61+
if (uriString.isNullOrBlank()) return null
62+
val query = uriString.substringAfter('?', "")
63+
if (query.isBlank()) return null
64+
return query
65+
.split('&')
66+
.asSequence()
67+
.mapNotNull { entry ->
68+
val separatorIndex = entry.indexOf('=')
69+
if (separatorIndex <= 0) return@mapNotNull null
70+
val key = entry.substring(0, separatorIndex)
71+
if (key != CACHE_BUST_QUERY) return@mapNotNull null
72+
entry.substring(separatorIndex + 1).takeIf { it.isNotBlank() }
73+
}
74+
.firstOrNull()
3875
}
3976

4077
fun isLikelyLocalMedia(contentUriString: String): Boolean {
@@ -56,14 +93,16 @@ object LocalArtworkUri {
5693
return normalizedStoredUri
5794
}
5895

59-
return if (isLocalArtworkUri(normalizedStoredUri) || looksLikeVolatileArtworkUri(normalizedStoredUri)) {
60-
if (normalizedStoredUri.contains("?t=")) {
61-
normalizedStoredUri
62-
} else {
63-
buildSongUri(songId)
96+
return when {
97+
isLocalArtworkUri(normalizedStoredUri) -> {
98+
if (normalizedStoredUri.contains("?t=")) {
99+
normalizedStoredUri
100+
} else {
101+
buildSongUri(songId)
102+
}
64103
}
65-
} else {
66-
normalizedStoredUri
104+
looksLikeVolatileArtworkUri(normalizedStoredUri) -> buildSongUri(songId)
105+
else -> normalizedStoredUri
67106
}
68107
}
69108
}

app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.core.content.FileProvider
77
import androidx.core.net.toUri
88
import androidx.media3.common.MediaItem
99
import androidx.media3.common.MediaMetadata
10+
import com.theveloper.pixelplay.data.provider.SharedArtworkContentProvider
1011
import com.theveloper.pixelplay.data.model.Song
1112
import java.io.File
1213

@@ -209,7 +210,19 @@ object MediaItemBuilder {
209210
fun externalControllerArtworkUri(context: Context, rawArtworkUri: String?): Uri? {
210211
if (LocalArtworkUri.isLocalArtworkUri(rawArtworkUri)) {
211212
val songId = rawArtworkUri?.let(LocalArtworkUri::parseSongId) ?: return null
212-
return AlbumArtUtils.getCachedAlbumArtUri(context.applicationContext, songId)
213+
return SharedArtworkContentProvider.buildSongUri(
214+
context = context.applicationContext,
215+
songId = songId,
216+
cacheBustToken = LocalArtworkUri.extractCacheBustToken(rawArtworkUri)
217+
)
218+
}
219+
220+
LocalArtworkUri.parseSongIdFromVolatileArtworkUri(rawArtworkUri)?.let { songId ->
221+
return SharedArtworkContentProvider.buildSongUri(
222+
context = context.applicationContext,
223+
songId = songId,
224+
cacheBustToken = LocalArtworkUri.extractCacheBustToken(rawArtworkUri)
225+
)
213226
}
214227

215228
val normalizedUri = normalizeArtworkUri(rawArtworkUri, SUPPORTED_EXTERNAL_ARTWORK_SCHEMES)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.theveloper.pixelplay.data.provider
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import org.junit.Test
5+
6+
class SharedArtworkContentProviderTest {
7+
8+
@Test
9+
fun buildSongUri_usesDedicatedArtworkAuthority() {
10+
val uri = SharedArtworkContentProvider.buildSongUriString(
11+
packageName = "com.theveloper.pixelplay",
12+
songId = 42L
13+
)
14+
15+
assertThat(uri).isEqualTo("content://com.theveloper.pixelplay.artwork/song/42")
16+
}
17+
18+
@Test
19+
fun buildSongUri_preservesCacheBustToken() {
20+
val uri = SharedArtworkContentProvider.buildSongUriString(
21+
packageName = "com.theveloper.pixelplay",
22+
songId = 42L,
23+
cacheBustToken = "1234"
24+
)
25+
26+
assertThat(uri)
27+
.isEqualTo("content://com.theveloper.pixelplay.artwork/song/42?t=1234")
28+
}
29+
30+
@Test
31+
fun parseSongId_rejectsOtherAuthorities() {
32+
val songId = SharedArtworkContentProvider.parseSongId(
33+
uriString = "content://example.com.artwork/song/42",
34+
packageName = "com.theveloper.pixelplay"
35+
)
36+
37+
assertThat(songId).isNull()
38+
}
39+
40+
@Test
41+
fun parseSongId_readsSharedArtworkSongUri() {
42+
val songId = SharedArtworkContentProvider.parseSongId(
43+
uriString = "content://com.theveloper.pixelplay.artwork/song/42",
44+
packageName = "com.theveloper.pixelplay"
45+
)
46+
47+
assertThat(songId).isEqualTo(42L)
48+
}
49+
}

app/src/test/java/com/theveloper/pixelplay/utils/LocalArtworkUriTest.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ class LocalArtworkUriTest {
1616
assertThat(resolved).isEqualTo(LocalArtworkUri.buildSongUri(42L))
1717
}
1818

19+
@Test
20+
fun resolveSongArtworkUri_convertsSharedArtworkUriToStableUri() {
21+
val resolved = LocalArtworkUri.resolveSongArtworkUri(
22+
storedUri = "content://com.theveloper.pixelplay.artwork/song/42?t=1234",
23+
songId = 42L,
24+
contentUriString = "content://media/external/audio/media/42"
25+
)
26+
27+
assertThat(resolved).isEqualTo(LocalArtworkUri.buildSongUri(42L))
28+
}
29+
1930
@Test
2031
fun resolveSongArtworkUri_keepsRemoteArtworkUriUntouched() {
2132
val resolved = LocalArtworkUri.resolveSongArtworkUri(
@@ -44,4 +55,22 @@ class LocalArtworkUriTest {
4455

4556
assertThat(songId).isEqualTo(99L)
4657
}
58+
59+
@Test
60+
fun parseSongIdFromVolatileArtworkUri_readsLegacyCacheFileName() {
61+
val songId = LocalArtworkUri.parseSongIdFromVolatileArtworkUri(
62+
"content://com.theveloper.pixelplay.provider/cache/song_art_77_v2.jpg"
63+
)
64+
65+
assertThat(songId).isEqualTo(77L)
66+
}
67+
68+
@Test
69+
fun extractCacheBustToken_readsTimestampQuery() {
70+
val cacheBustToken = LocalArtworkUri.extractCacheBustToken(
71+
"pixelplay_local_art://song/99?t=456"
72+
)
73+
74+
assertThat(cacheBustToken).isEqualTo("456")
75+
}
4776
}

0 commit comments

Comments
 (0)