EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- UPnP/DLNA Discovery Permissions -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
    <!-- WAKE_LOCK is required by jUPnP's AndroidRouter, which acquires a
         WifiLock (NOT a wake lock — confusingly named) to keep the Wi-Fi
         radio in high-performance mode during UPnP discovery. WifiLock.acquire()
         is gated on this permission at the system level. Without it, the very
         first call into UpnpServiceImpl.startup() throws SecurityException and
         the entire UPnP stack fails to initialize — silently, because the
         binder catches it. The phone shows zero servers and there is no
         user-visible error. Symptom indistinguishable from "no servers on
         network." Discovered via logcat: AndroidRouter.setWifiLock → 
         WifiLock.acquire → SecurityException. -->
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    
    <!-- Playback Service Permission -->
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

    <!-- Local media library — read audio files from device storage.
         READ_MEDIA_AUDIO is the granular permission on API 33+;
         READ_EXTERNAL_STORAGE is the legacy fallback for API 29-32. -->
    <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
        android:maxSdkVersion="32" />

    <!-- Voice search delegates to the system's RecognizerIntent (the same
         path the system keyboard's mic button uses). Audio capture happens
         in the system's recognizer activity, not in our process — so we
         don't declare RECORD_AUDIO. The <queries> block below is kept so
         PackageManager.queryIntentActivities can resolve the recognizer
         activity from our process; without it, on API 30+ launching the
         intent silently no-ops. -->

    <queries>
        <intent>
            <action android:name="android.speech.RecognitionService" />
        </intent>
        <intent>
            <action android:name="android.speech.action.RECOGNIZE_SPEECH" />
        </intent>
        <!-- Kiosk hand-back: resolve the launch intent of the `returnTo`
             package named by an OBSERVE launch (FaveatHaus on the wall
             screens). API 30+ package-visibility filtering makes
             getLaunchIntentForPackage return null for unqueried packages —
             these two filters grant visibility of launcher-able apps on
             both touch and leanback form factors. -->
        <intent>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
        </intent>
        <intent>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
        </intent>
    </queries>

    <!-- TV: declares leanback support. required=false so the app can also
         install on phones for Android Auto. The LEANBACK_LAUNCHER category
         in the activity's intent-filter is what makes it appear on TV. -->
    <uses-feature android:name="android.software.leanback" android:required="false" />
    <uses-feature android:name="android.hardware.touchscreen" android:required="false" />

    <!-- Decision, 2026-10-29: backup rules (D9). Auto Backup used to copy
         everything, including the settings DataStore that holds this
         device's identity (renderer UDN, kiosk return package, observed Cast
         device, last-known hosts), so a restored phone impersonated the old
         one. backup_rules / data_extraction_rules exclude only that file. -->
    <application
        android:name=".EarhornApp"
        android:allowBackup="true"
        android:fullBackupContent="@xml/backup_rules"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:icon="@mipmap/ic_launcher"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:label="@string/app_name"
        android:logo="@mipmap/ic_launcher" 
        android:banner="@drawable/tv_banner"
        android:theme="@style/Theme.Earhorn"
        android:usesCleartextTraffic="true"
        tools:targetApi="35">

        <!-- launchMode=singleTask: required for the voice-cast auto-observe
             entry point. FaveatHaus fires OBSERVE while Earhorn is in the
             BACKGROUND; with the default launchMode that would stack a second
             MainActivity instance (and onNewIntent would never fire).
             singleTask guarantees exactly one instance, brings its task
             forward, and delivers repeat launches through onNewIntent — which
             is also what the notification deep-links already assumed. -->
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTask"
            android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
            android:theme="@style/Theme.Earhorn">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
                <!-- This line is critical for Android TV visibility -->
                <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
            </intent-filter>

            <!-- E1 primary: exported observe entry point for the voice-cast
                 pipeline (musicnow > FaveatHaus > us). String extra "group"
                 carries the Cast group friendly name exactly as Google Home
                 spells it (UTF-8; spaces and umlauts intact).
                 Verification command: see docs/voice-cast-observe.md
                 (adb "am start" with the extra; the literal flag can't be
                 quoted here because XML comments forbid double hyphens).
                 DEFAULT category is mandatory: implicit startActivity (and
                 adb "am start" by action) only resolves filters carrying it. -->
            <intent-filter>
                <action android:name="com.faveat.earhorn.action.OBSERVE" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>

            <!-- E1 secondary: earhorn://observe?group=<url-encoded name> -->
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="earhorn" android:host="observe" />
            </intent-filter>
        </activity>

        <!-- jUPnP UPnP Service (successor to the abandoned Cling project) -->
        <service 
            android:name="org.jupnp.android.AndroidUpnpServiceImpl" 
            android:enabled="true"
            android:exported="false" />

        <!-- Media3 Playback + Browse Service. Extends MediaLibraryService so
             both the TV launcher (MediaSession) and Android Auto (browse tree)
             work from the same service. exported=true is required for Auto to
             bind from the car head unit process. -->
        <service
            android:name=".playback.PlaybackService"
            android:enabled="true"
            android:exported="true"
            android:foregroundServiceType="mediaPlayback">
            <intent-filter>
                <action android:name="androidx.media3.session.MediaLibraryService" />
                <action android:name="android.media.browse.MediaBrowserService" />
            </intent-filter>
        </service>

        <!-- Decision, 2026-10-29: Media3's media-button receiver, so a
             Bluetooth / headset Play reaches PlaybackService (and playback
             resumption) even when the app isn't running (D8). Subclassed so
             it only starts the service when there is a queue to resume —
             otherwise Android kills the app for a foreground service that
             never posted its notification. -->
        <receiver
            android:name=".playback.EarhornMediaButtonReceiver"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MEDIA_BUTTON" />
            </intent-filter>
        </receiver>

        <!-- Android Auto: declares this app provides media playback. -->
        <meta-data
            android:name="com.google.android.gms.car.application"
            android:resource="@xml/automotive_app_desc" />


        <service
            android:name=".cartemplate.EarhornCarAppService"
            android:exported="true">
            <intent-filter>
                <action android:name="androidx.car.app.CarAppService" />
                <category android:name="androidx.car.app.category.MEDIA" />
            </intent-filter>
            <meta-data
                android:name="androidx.car.app.minCarApiLevel"
                android:value="7" />
        </service>

    </application>
</manifest>