diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCommandService.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCommandService.kt
index c1a01ae..9b23b1b 100644
--- a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCommandService.kt
+++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCommandService.kt
@@ -15,6 +15,14 @@ import com.facebook.react.HeadlessJsTaskService
import com.facebook.react.bridge.Arguments
import com.facebook.react.jstasks.HeadlessJsTaskConfig
+private const val NOTIFICATION_ICON_RESOURCE = "astra_notification_icon"
+
+private fun notificationSmallIcon(context: Context): Int =
+ context.resources
+ .getIdentifier(NOTIFICATION_ICON_RESOURCE, "drawable", context.packageName)
+ .takeIf { it != 0 }
+ ?: android.R.drawable.ic_media_play
+
class AstraCarCommandService : HeadlessJsTaskService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// We're started via startForegroundService (so transport from the car works even when
@@ -72,7 +80,7 @@ class AstraCarCommandService : HeadlessJsTaskService() {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Astra")
.setContentText("Handling car controls")
- .setSmallIcon(android.R.drawable.ic_media_play)
+ .setSmallIcon(notificationSmallIcon(this))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
diff --git a/modules/astra-desktop-remote-session/android/src/main/java/expo/modules/astradesktopremotesession/AstraDesktopRemoteSessionModule.kt b/modules/astra-desktop-remote-session/android/src/main/java/expo/modules/astradesktopremotesession/AstraDesktopRemoteSessionModule.kt
index 049f8c5..db5f8ed 100644
--- a/modules/astra-desktop-remote-session/android/src/main/java/expo/modules/astradesktopremotesession/AstraDesktopRemoteSessionModule.kt
+++ b/modules/astra-desktop-remote-session/android/src/main/java/expo/modules/astradesktopremotesession/AstraDesktopRemoteSessionModule.kt
@@ -35,6 +35,13 @@ private const val ACTION_NEXT = ACTION_PREFIX + "NEXT"
private const val ACTION_TOGGLE_FAVORITE = ACTION_PREFIX + "TOGGLE_FAVORITE"
private const val ACTION_STOP = ACTION_PREFIX + "STOP"
private const val MAX_ART_EDGE = 512
+private const val NOTIFICATION_ICON_RESOURCE = "astra_notification_icon"
+
+private fun notificationSmallIcon(context: Context): Int =
+ context.resources
+ .getIdentifier(NOTIFICATION_ICON_RESOURCE, "drawable", context.packageName)
+ .takeIf { it != 0 }
+ ?: android.R.drawable.ic_media_play
class AstraDesktopRemoteSessionState : Record {
@Field
@@ -240,7 +247,7 @@ object AstraDesktopRemoteSessionController {
}
val largeIcon = decodeDataUrlBitmap(state.artworkDataUrl)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
- .setSmallIcon(android.R.drawable.ic_media_play)
+ .setSmallIcon(notificationSmallIcon(context))
.setContentTitle(state.title?.ifBlank { "Unknown track" } ?: "Unknown track")
.setContentText(state.artist?.ifBlank { state.desktopName } ?: state.desktopName ?: "Astra Desktop")
.setSubText(state.desktopName ?: "Desktop Remote")
diff --git a/plugins/withAstraAndroidRelease.js b/plugins/withAstraAndroidRelease.js
index d0180dd..9a75d86 100644
--- a/plugins/withAstraAndroidRelease.js
+++ b/plugins/withAstraAndroidRelease.js
@@ -1,5 +1,8 @@
+const fs = require('fs/promises');
+const path = require('path');
const {
AndroidConfig,
+ withDangerousMod,
withAndroidManifest,
withAppBuildGradle,
withProjectBuildGradle,
@@ -10,6 +13,41 @@ const SETTINGS_MARKER = '// ASTRA VENDORED KOTLIN AUDIO';
const PROJECT_BUILD_MARKER = '// ASTRA KOTLIN AUDIO SUBSTITUTION';
const SIGNING_MARKER = '// ASTRA RELEASE SIGNING';
const CAMERA_FEATURE = 'android.hardware.camera';
+const NOTIFICATION_ICON_FILE = 'astra_notification_icon.xml';
+const NOTIFICATION_OVERRIDE_FILE = 'astra_notification_icon_overrides.xml';
+
+// A notification small icon is an alpha mask: Android supplies the color for
+// the status bar, lock screen and media controls. Keep only the main Astra mark
+// here—launcher backgrounds, shadows and brand colors do not belong in it.
+const NOTIFICATION_ICON_VECTOR = `
+
+
+
+
+
+
+`;
+
+// RNTP 4.1.2 uses ExoPlayer's generic circular-play drawable whenever no JS
+// icon resolves. An app-level resource with the same name overrides that
+// default in release, debug and the temporary foreground-service notification.
+const NOTIFICATION_ICON_OVERRIDE = `
+
+ - @drawable/astra_notification_icon
+
+`;
const SETTINGS_BLOCK = `
@@ -158,9 +196,35 @@ function ensureOptionalCameraFeature(androidManifest) {
manifest['uses-feature'] = features;
}
+async function writeNotificationIconResources(platformProjectRoot) {
+ const resourceRoot = path.join(platformProjectRoot, 'app', 'src', 'main', 'res');
+ const drawableDir = path.join(resourceRoot, 'drawable');
+ const valuesDir = path.join(resourceRoot, 'values');
+
+ await Promise.all([
+ fs.mkdir(drawableDir, { recursive: true }),
+ fs.mkdir(valuesDir, { recursive: true }),
+ ]);
+ await Promise.all([
+ fs.writeFile(path.join(drawableDir, NOTIFICATION_ICON_FILE), NOTIFICATION_ICON_VECTOR),
+ fs.writeFile(path.join(valuesDir, NOTIFICATION_OVERRIDE_FILE), NOTIFICATION_ICON_OVERRIDE),
+ ]);
+}
+
+function withAstraNotificationIcon(config) {
+ return withDangerousMod(config, [
+ 'android',
+ async (mod) => {
+ await writeNotificationIconResources(mod.modRequest.platformProjectRoot);
+ return mod;
+ },
+ ]);
+}
+
function withAstraAndroidRelease(config) {
config = withVendoredKotlinAudio(config);
config = withReleaseSigning(config);
+ config = withAstraNotificationIcon(config);
return withProfileableRelease(config);
}
@@ -169,7 +233,12 @@ module.exports._internal = {
PROJECT_BUILD_MARKER,
SETTINGS_MARKER,
SIGNING_MARKER,
+ NOTIFICATION_ICON_FILE,
+ NOTIFICATION_ICON_VECTOR,
+ NOTIFICATION_OVERRIDE_FILE,
+ NOTIFICATION_ICON_OVERRIDE,
addReleaseSigning,
appendBlock,
ensureOptionalCameraFeature,
+ writeNotificationIconResources,
};
diff --git a/plugins/withAstraAndroidRelease.test.mjs b/plugins/withAstraAndroidRelease.test.mjs
index b95fa1b..d6a61ae 100644
--- a/plugins/withAstraAndroidRelease.test.mjs
+++ b/plugins/withAstraAndroidRelease.test.mjs
@@ -1,5 +1,8 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
+import { mkdtemp, readFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
import test from 'node:test';
const require = createRequire(import.meta.url);
@@ -65,3 +68,36 @@ test('marks QR scanning camera hardware as optional without duplicating it', ()
},
]);
});
+
+test('writes deterministic Astra and RNTP notification icon resources', async (t) => {
+ const projectRoot = await mkdtemp(path.join(tmpdir(), 'astra-notification-icon-'));
+ t.after(() => rm(projectRoot, { recursive: true, force: true }));
+
+ await _internal.writeNotificationIconResources(projectRoot);
+ const drawablePath = path.join(
+ projectRoot,
+ 'app/src/main/res/drawable',
+ _internal.NOTIFICATION_ICON_FILE
+ );
+ const overridePath = path.join(
+ projectRoot,
+ 'app/src/main/res/values',
+ _internal.NOTIFICATION_OVERRIDE_FILE
+ );
+ const firstDrawable = await readFile(drawablePath, 'utf8');
+ const firstOverride = await readFile(overridePath, 'utf8');
+
+ assert.equal(firstDrawable, _internal.NOTIFICATION_ICON_VECTOR);
+ assert.match(firstDrawable, /android:width="24dp"/);
+ assert.match(firstDrawable, /android:fillColor="#FFFFFFFF"/);
+ assert.doesNotMatch(firstDrawable, /background|logoShadow/);
+ assert.equal(firstOverride, _internal.NOTIFICATION_ICON_OVERRIDE);
+ assert.match(
+ firstOverride,
+ /name="exo_notification_small_icon" type="drawable">@drawable\/astra_notification_icon/
+ );
+
+ await _internal.writeNotificationIconResources(projectRoot);
+ assert.equal(await readFile(drawablePath, 'utf8'), firstDrawable);
+ assert.equal(await readFile(overridePath, 'utf8'), firstOverride);
+});