diff --git a/android/app/src/main/assets/changelog.json b/android/app/src/main/assets/changelog.json index 815db1d90..f7507935f 100644 --- a/android/app/src/main/assets/changelog.json +++ b/android/app/src/main/assets/changelog.json @@ -1,4 +1,19 @@ [ + { + "versionName": "1.0.10", + "releaseDate": "21/01/2026", + "features": [ + + ], + "fixes": [ + "Fix an Android Auto issue where library wasn't displayed correctly" + ], + "improvements": [ + ], + "notes": [ + "What's up? I want to make Shuttle great again. I just need more hours in the day, and more days in the week!" + ] + }, { "versionName": "1.0.9", "releaseDate": "15/01/2026", diff --git a/android/playback/src/main/java/com/simplecityapps/playback/PlaybackService.kt b/android/playback/src/main/java/com/simplecityapps/playback/PlaybackService.kt index ade97806e..b14666d69 100644 --- a/android/playback/src/main/java/com/simplecityapps/playback/PlaybackService.kt +++ b/android/playback/src/main/java/com/simplecityapps/playback/PlaybackService.kt @@ -62,7 +62,7 @@ class PlaybackService : private var delayedShutdownHandler: Handler? = null - private val packageValidator: PackageValidator by lazy { PackageValidator(this) } + private val packageValidator: PackageValidator by lazy { PackageValidator(this, R.xml.allowed_media_browser_callers) } private val coroutineScope = CoroutineScope(Dispatchers.Main) @@ -310,7 +310,7 @@ class PlaybackService : clientPackageName: String, clientUid: Int, rootHints: Bundle? - ): BrowserRoot? = if (packageValidator.isCallerAllowed(this, clientPackageName, clientUid)) { + ): BrowserRoot? = if (packageValidator.isKnownCaller(clientPackageName, clientUid)) { BrowserRoot("media:/root/", null) } else { Timber.v("OnGetRoot: Browsing NOT ALLOWED for unknown caller. Returning empty browser root so all apps can use MediaController. $clientPackageName") diff --git a/android/playback/src/main/java/com/simplecityapps/playback/androidauto/LogHelper.java b/android/playback/src/main/java/com/simplecityapps/playback/androidauto/LogHelper.java deleted file mode 100644 index 27ed0b7e6..000000000 --- a/android/playback/src/main/java/com/simplecityapps/playback/androidauto/LogHelper.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.simplecityapps.playback.androidauto; - -import timber.log.Timber; - -public class LogHelper { - - public static String makeLogTag(Class cls) { - return cls.getSimpleName(); - } - - public static void v(String tag, Object... messages) { - Timber.tag(tag).v(concatMessages(messages)); - } - - public static void w(String tag, Object... messages) { - Timber.tag(tag).w(concatMessages(messages)); - } - - public static void i(String tag, Object... messages) { - Timber.tag(tag).i(concatMessages(messages)); - } - - public static void e(String tag, Throwable throwable, Object... messages) { - Timber.tag(tag).e(throwable, concatMessages(messages)); - } - - private static String concatMessages(Object... messages) { - StringBuilder builder = new StringBuilder(); - for (Object msg : messages) { - builder.append(msg); - } - return builder.toString(); - } -} diff --git a/android/playback/src/main/java/com/simplecityapps/playback/androidauto/PackageValidator.java b/android/playback/src/main/java/com/simplecityapps/playback/androidauto/PackageValidator.java deleted file mode 100644 index a1e348f42..000000000 --- a/android/playback/src/main/java/com/simplecityapps/playback/androidauto/PackageValidator.java +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright (C) 2014 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.simplecityapps.playback.androidauto; - -import android.content.Context; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.content.res.XmlResourceParser; -import android.os.Process; -import android.util.Base64; - -import com.simplecityapps.playback.R; - -import org.xmlpull.v1.XmlPullParserException; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; - -/** - * Validates that the calling package is authorized to browse a - * {@link android.service.media.MediaBrowserService}. - * - * The list of allowed signing certificates and their corresponding package names is defined in - * res/xml/allowed_media_browser_callers.xml. - * - * If you add a new valid caller to allowed_media_browser_callers.xml and you don't know - * its signature, this class will print to logcat (INFO level) a message with the proper base64 - * version of the caller certificate that has not been validated. You can copy from logcat and - * paste into allowed_media_browser_callers.xml. Spaces and newlines are ignored. - */ -public class PackageValidator { - private static final String TAG = LogHelper.makeLogTag(PackageValidator.class); - - /** - * Map allowed callers' certificate keys to the expected caller information. - * - */ - private final Map> mValidCertificates; - - public PackageValidator(Context ctx) { - mValidCertificates = readValidCertificates(ctx.getResources().getXml( - R.xml.allowed_media_browser_callers)); - } - - private Map> readValidCertificates(XmlResourceParser parser) { - HashMap> validCertificates = new HashMap<>(); - try { - int eventType = parser.next(); - while (eventType != XmlResourceParser.END_DOCUMENT) { - if (eventType == XmlResourceParser.START_TAG - && parser.getName().equals("signing_certificate")) { - - String name = parser.getAttributeValue(null, "name"); - String packageName = parser.getAttributeValue(null, "package"); - boolean isRelease = parser.getAttributeBooleanValue(null, "release", false); - String certificate = parser.nextText().replaceAll("\\s|\\n", ""); - - CallerInfo info = new CallerInfo(name, packageName, isRelease); - - ArrayList infos = validCertificates.get(certificate); - if (infos == null) { - infos = new ArrayList<>(); - validCertificates.put(certificate, infos); - } - LogHelper.v(TAG, "Adding allowed caller: ", info.name, - " package=", info.packageName, " release=", info.release, - " certificate=", certificate); - infos.add(info); - } - eventType = parser.next(); - } - } catch (XmlPullParserException | IOException e) { - LogHelper.e(TAG, e, "Could not read allowed callers from XML."); - } - return validCertificates; - } - - /** - * @return false if the caller is not authorized to get data from this MediaBrowserService - */ - @SuppressWarnings("BooleanMethodIsAlwaysInverted") - public boolean isCallerAllowed(Context context, String callingPackage, int callingUid) { - // Always allow calls from the framework, self app or development environment. - if (Process.SYSTEM_UID == callingUid || Process.myUid() == callingUid) { - return true; - } - - if (isPlatformSigned(context, callingPackage)) { - return true; - } - - PackageInfo packageInfo = getPackageInfo(context, callingPackage); - if (packageInfo == null) { - return false; - } - if (packageInfo.signatures.length != 1) { - LogHelper.w(TAG, "Caller does not have exactly one signature certificate!"); - return false; - } - String signature = Base64.encodeToString( - packageInfo.signatures[0].toByteArray(), Base64.NO_WRAP); - - // Test for known signatures: - ArrayList validCallers = mValidCertificates.get(signature); - if (validCallers == null) { - LogHelper.v(TAG, "Signature for caller ", callingPackage, " is not valid: \n" - , signature); - if (mValidCertificates.isEmpty()) { - LogHelper.w(TAG, "The list of valid certificates is empty. Either your file ", - "res/xml/allowed_media_browser_callers.xml is empty or there was an error ", - "while reading it. Check previous log messages."); - } - return false; - } - - // Check if the package name is valid for the certificate: - StringBuffer expectedPackages = new StringBuffer(); - for (CallerInfo info: validCallers) { - if (callingPackage.equals(info.packageName)) { - LogHelper.v(TAG, "Valid caller: ", info.name, " package=", info.packageName, - " release=", info.release); - return true; - } - expectedPackages.append(info.packageName).append(' '); - } - - LogHelper.i(TAG, "Caller has a valid certificate, but its package doesn't match any ", - "expected package for the given certificate. Caller's package is ", callingPackage, - ". Expected packages as defined in res/xml/allowed_media_browser_callers.xml are (", - expectedPackages, "). This caller's certificate is: \n", signature); - - return false; - } - - /** - * @return true if the installed package signature matches the platform signature. - */ - private boolean isPlatformSigned(Context context, String pkgName) { - PackageInfo platformPackageInfo = getPackageInfo(context, "android"); - - // Should never happen. - if (platformPackageInfo == null || platformPackageInfo.signatures == null - || platformPackageInfo.signatures.length == 0) { - return false; - } - - PackageInfo clientPackageInfo = getPackageInfo(context, pkgName); - - return (clientPackageInfo != null && clientPackageInfo.signatures != null - && clientPackageInfo.signatures.length > 0 && - platformPackageInfo.signatures[0].equals(clientPackageInfo.signatures[0])); - } - - /** - * @return {@link PackageInfo} for the package name or null if it's not found. - */ - private PackageInfo getPackageInfo(Context context, String pkgName) { - try { - final PackageManager pm = context.getPackageManager(); - return pm.getPackageInfo(pkgName, PackageManager.GET_SIGNATURES); - } catch (PackageManager.NameNotFoundException e) { - LogHelper.w(TAG, e, "Package manager can't find package: ", pkgName); - } - return null; - } - - private final static class CallerInfo { - final String name; - final String packageName; - final boolean release; - - public CallerInfo(String name, String packageName, boolean release) { - this.name = name; - this.packageName = packageName; - this.release = release; - } - } -} diff --git a/android/playback/src/main/java/com/simplecityapps/playback/androidauto/PackageValidator.kt b/android/playback/src/main/java/com/simplecityapps/playback/androidauto/PackageValidator.kt new file mode 100644 index 000000000..6df0f5186 --- /dev/null +++ b/android/playback/src/main/java/com/simplecityapps/playback/androidauto/PackageValidator.kt @@ -0,0 +1,358 @@ +/* + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.simplecityapps.playback.androidauto + +import android.Manifest.permission.BIND_NOTIFICATION_LISTENER_SERVICE +import android.Manifest.permission.MEDIA_CONTENT_CONTROL +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED +import android.content.pm.PackageManager +import android.content.res.XmlResourceParser +import android.os.Process +import android.support.v4.media.session.MediaSessionCompat +import android.util.Base64 +import androidx.annotation.XmlRes +import androidx.media.MediaBrowserServiceCompat +import com.simplecityapps.playback.BuildConfig +import java.io.IOException +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import org.xmlpull.v1.XmlPullParserException +import timber.log.Timber + +/** + * Validates that the calling package is authorized to browse a [MediaBrowserServiceCompat]. + * + * The list of allowed signing certificates and their corresponding package names is defined in + * res/xml/allowed_media_browser_callers.xml. + * + * If you want to add a new caller to allowed_media_browser_callers.xml and you don't know + * its signature, this class will print to logcat (INFO level) a message with the proper + * xml tags to add to allow the caller. + * + * For more information, see res/xml/allowed_media_browser_callers.xml. + */ +class PackageValidator( + context: Context, + @XmlRes xmlResId: Int +) { + private val context: Context + private val packageManager: PackageManager + + private val certificateWhitelist: Map + private val platformSignature: String + + private val callerChecked = mutableMapOf>() + + init { + val parser = context.resources.getXml(xmlResId) + this.context = context.applicationContext + this.packageManager = this.context.packageManager + + certificateWhitelist = buildCertificateWhitelist(parser) + platformSignature = getSystemSignature() + } + + /** + * Checks whether the caller attempting to connect to a [MediaBrowserServiceCompat] is known. + * See [MusicService.onGetRoot] for where this is utilized. + * + * @param callingPackage The package name of the caller. + * @param callingUid The user id of the caller. + * @return `true` if the caller is known, `false` otherwise. + */ + fun isKnownCaller( + callingPackage: String, + callingUid: Int + ): Boolean { + // If the caller has already been checked, return the previous result here. + val (checkedUid, checkResult) = callerChecked[callingPackage] ?: Pair(0, false) + if (checkedUid == callingUid) { + return checkResult + } + + /** + * Because some of these checks can be slow, we save the results in [callerChecked] after + * this code is run. + * + * In particular, there's little reason to recompute the calling package's certificate + * signature (SHA-256) each call. + * + * This is safe to do as we know the UID matches the package's UID (from the check above), + * and app UIDs are set at install time. Additionally, a package name + UID is guaranteed to + * be constant until a reboot. (After a reboot then a previously assigned UID could be + * reassigned.) + */ + + // Build the caller info for the rest of the checks here. + val callerPackageInfo = + buildCallerInfo(callingPackage) + ?: throw IllegalStateException("Caller wasn't found in the system?") + + // Verify that things aren't ... broken. (This test should always pass.) + if (callerPackageInfo.uid != callingUid) { + throw IllegalStateException("Caller's package UID doesn't match caller's actual UID?") + } + + val callerSignature = callerPackageInfo.signature + val isPackageInWhitelist = + certificateWhitelist[callingPackage]?.signatures?.firstOrNull { + it.signature == callerSignature + } != null + + val isCallerKnown = + when { + // If it's our own app making the call, allow it. + callingUid == Process.myUid() -> true + // If it's one of the apps on the whitelist, allow it. + isPackageInWhitelist -> true + // If the system is making the call, allow it. + callingUid == Process.SYSTEM_UID -> true + // If the app was signed by the same certificate as the platform itself, also allow it. + callerSignature == platformSignature -> true + /** + * [MEDIA_CONTENT_CONTROL] permission is only available to system applications, and + * while it isn't required to allow these apps to connect to a + * [MediaBrowserServiceCompat], allowing this ensures optimal compatability with apps + * such as Android TV and the Google Assistant. + */ + callerPackageInfo.permissions.contains(MEDIA_CONTENT_CONTROL) -> true + /** + * This last permission can be specifically granted to apps, and, in addition to + * allowing them to retrieve notifications, it also allows them to connect to an + * active [MediaSessionCompat]. + * As with the above, it's not required to allow apps holding this permission to + * connect to your [MediaBrowserServiceCompat], but it does allow easy comparability + * with apps such as Wear OS. + */ + callerPackageInfo.permissions.contains(BIND_NOTIFICATION_LISTENER_SERVICE) -> true + // If none of the pervious checks succeeded, then the caller is unrecognized. + else -> false + } + + if (!isCallerKnown) { + logUnknownCaller(callerPackageInfo) + } + + // Save our work for next time. + callerChecked[callingPackage] = Pair(callingUid, isCallerKnown) + return isCallerKnown + } + + /** + * Logs an info level message with details of how to add a caller to the allowed callers list + * when the app is debuggable. + */ + private fun logUnknownCaller(callerPackageInfo: CallerPackageInfo) { + if (BuildConfig.DEBUG && callerPackageInfo.signature != null) { + Timber.i( + "Unknown caller: %s (%s) with signature: %s", + callerPackageInfo.name, + callerPackageInfo.packageName, + callerPackageInfo.signature + ) + } + } + + /** + * Builds a [CallerPackageInfo] for a given package that can be used for all the + * various checks that are performed before allowing an app to connect to a + * [MediaBrowserServiceCompat]. + */ + private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? { + val packageInfo = getPackageInfo(callingPackage) ?: return null + val applicationInfo = packageInfo.applicationInfo ?: return null + + val appName = applicationInfo.loadLabel(packageManager).toString() + val uid = applicationInfo.uid + val signature = getSignature(packageInfo) + + val requestedPermissions = packageInfo.requestedPermissions + val permissionFlags = packageInfo.requestedPermissionsFlags ?: intArrayOf() + val activePermissions = mutableSetOf() + requestedPermissions?.forEachIndexed { index, permission -> + if (permissionFlags[index] and REQUESTED_PERMISSION_GRANTED != 0) { + activePermissions += permission + } + } + + return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet()) + } + + /** + * Looks up the [PackageInfo] for a package name. + * This requests both the signatures (for checking if an app is on the whitelist) and + * the app's permissions, which allow for more flexibility in the whitelist. + * + * @return [PackageInfo] for the package name or null if it's not found. + */ + @SuppressLint("PackageManagerGetSignatures") + private fun getPackageInfo(callingPackage: String): PackageInfo? = packageManager.getPackageInfo( + callingPackage, + PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS + ) + + /** + * Gets the signature of a given package's [PackageInfo]. + * + * The "signature" is a SHA-256 hash of the public key of the signing certificate used by + * the app. + * + * If the app is not found, or if the app does not have exactly one signature, this method + * returns `null` as the signature. + */ + private fun getSignature(packageInfo: PackageInfo): String? { + val signatures = packageInfo.signatures + return if (signatures == null || signatures.size != 1) { + // Security best practices dictate that an app should be signed with exactly one (1) + // signature. Because of this, if there are multiple signatures, reject it. + null + } else { + val certificate = signatures[0].toByteArray() + getSignatureSha256(certificate) + } + } + + private fun buildCertificateWhitelist(parser: XmlResourceParser): Map { + val certificateWhitelist = LinkedHashMap() + try { + var eventType = parser.next() + while (eventType != XmlResourceParser.END_DOCUMENT) { + if (eventType == XmlResourceParser.START_TAG) { + val callerInfo = + when (parser.name) { + "signing_certificate" -> parseV1Tag(parser) + "signature" -> parseV2Tag(parser) + else -> null + } + + callerInfo?.let { info -> + val packageName = info.packageName + val existingCallerInfo = certificateWhitelist[packageName] + if (existingCallerInfo != null) { + existingCallerInfo.signatures += callerInfo.signatures + } else { + certificateWhitelist[packageName] = callerInfo + } + } + } + + eventType = parser.next() + } + } catch (xmlException: XmlPullParserException) { + Timber.e(xmlException, "Could not read allowed callers from XML.") + } catch (ioException: IOException) { + Timber.e(ioException, "Could not read allowed callers from XML.") + } + + return certificateWhitelist + } + + /** + * Parses a v1 format tag. See allowed_media_browser_callers.xml for more details. + */ + private fun parseV1Tag(parser: XmlResourceParser): KnownCallerInfo { + val name = parser.getAttributeValue(null, "name") + val packageName = parser.getAttributeValue(null, "package") + val isRelease = parser.getAttributeBooleanValue(null, "release", false) + val certificate = parser.nextText().replace(WHITESPACE_REGEX, "") + val signature = getSignatureSha256(certificate) + + val callerSignature = KnownSignature(signature, isRelease) + return KnownCallerInfo(name, packageName, mutableSetOf(callerSignature)) + } + + /** + * Parses a v2 format tag. See allowed_media_browser_callers.xml for more details. + */ + private fun parseV2Tag(parser: XmlResourceParser): KnownCallerInfo { + val name = parser.getAttributeValue(null, "name") + val packageName = parser.getAttributeValue(null, "package") + + val callerSignatures = mutableSetOf() + var eventType = parser.next() + while (eventType != XmlResourceParser.END_TAG) { + val isRelease = parser.getAttributeBooleanValue(null, "release", false) + val signature = parser.nextText().replace(WHITESPACE_REGEX, "").lowercase() + callerSignatures += KnownSignature(signature, isRelease) + + eventType = parser.next() + } + + return KnownCallerInfo(name, packageName, callerSignatures) + } + + /** + * Finds the Android platform signing key signature. This key is never null. + */ + private fun getSystemSignature(): String = getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo -> + getSignature(platformInfo) + } ?: throw IllegalStateException("Platform signature not found") + + /** + * Creates a SHA-256 signature given a Base64 encoded certificate. + */ + private fun getSignatureSha256(certificate: String): String = getSignatureSha256(Base64.decode(certificate, Base64.DEFAULT)) + + /** + * Creates a SHA-256 signature given a certificate byte array. + */ + private fun getSignatureSha256(certificate: ByteArray): String { + val md: MessageDigest + try { + md = MessageDigest.getInstance("SHA256") + } catch (noSuchAlgorithmException: NoSuchAlgorithmException) { + Timber.e("No such algorithm: $noSuchAlgorithmException") + throw RuntimeException("Could not find SHA256 hash algorithm", noSuchAlgorithmException) + } + md.update(certificate) + + // This code takes the byte array generated by `md.digest()` and joins each of the bytes + // to a string, applying the string format `%02x` on each digit before it's appended, with + // a colon (':') between each of the items. + // For example: input=[0,2,4,6,8,10,12], output="00:02:04:06:08:0a:0c" + return md.digest().joinToString(":") { String.format("%02x", it) } + } + + private data class KnownCallerInfo( + val name: String, + val packageName: String, + val signatures: MutableSet + ) + + private data class KnownSignature( + val signature: String, + val release: Boolean + ) + + /** + * Convenience class to hold all of the information about an app that's being checked + * to see if it's a known caller. + */ + private data class CallerPackageInfo( + val name: String, + val packageName: String, + val uid: Int, + val signature: String?, + val permissions: Set + ) +} + +private const val ANDROID_PLATFORM = "android" +private val WHITESPACE_REGEX = "\\s|\\n".toRegex() diff --git a/buildSrc/src/main/kotlin/AppVersion.kt b/buildSrc/src/main/kotlin/AppVersion.kt index 200b52e54..3c35a2f18 100644 --- a/buildSrc/src/main/kotlin/AppVersion.kt +++ b/buildSrc/src/main/kotlin/AppVersion.kt @@ -1,6 +1,6 @@ object AppVersion { const val versionMajor = 1 const val versionMinor = 0 - const val versionPatch = 9 + const val versionPatch = 10 val versionSuffix: String? = "" }