Zurück zum Blog
Transport16 min read

DLNA Cast: Aufbau eines UPnP-Senders und -Empfängers von Grund auf

Wie PlainApp DLNA/UPnP-AV-Casting auf beiden Seiten implementiert: Scannen und Steuern von Fernsehern per SOAP als Sender sowie Verwandeln des Telefons selbst in einen UPnP-MediaRenderer als Empfänger – mit SSDP-Discovery, DIDL-Lite-Metadaten, Range-Request-basiertem Media-Serving, GENA-Event-Callbacks und einem Sender-IP-Allow/Deny-Vertrauensmodell, alles in reinem Kotlin Multiplatform.

DLNA (aufbauend auf UPnP AV) ist das Protokoll hinter den „Auf den Fernseher casten"-Buttons von Smart-TVs. Es läuft vollständig im lokalen Netzwerk, ohne Cloud-Konto und ohne Kopplungsschritt. PlainApp implementiert beide Richtungen davon: Es kann ein lokales Video, einen Song oder ein Foto auf jeden DLNA-kompatiblen Smart-TV pushen, und es kann das Telefon selbst in einen MediaRenderer verwandeln, sodass eine TV-Fernbedienungs-App, VLC oder eine andere PlainApp-Instanz darauf casten kann.

Dieser Artikel behandelt das Drahtprotokoll (SSDP + SOAP + DIDL-Lite), den lokalen HTTP-Server, der Medien mit den Headern ausliefert, die Fernseher tatsächlich benötigen, das GENA-Event-Subscription für den Wiedergabestatus und das Sender-IP-Vertrauensmodell, das ein unauthentifiziertes Protokoll aus den 1990er-Jahren auf einem modernen Telefon sicher betreibt.

Inhaltsverzeichnis

High-Level-Architektur

Diagram 1
1

DLNA/UPnP AV hat keinen zentralen Server und keine Cloud-Komponente – alles geschieht über lokales Netzwerk-UDP-Multicast (Discovery) und HTTP (Steuerung + Medien). PlainApp implementiert zwei unabhängige Rollen, die sich denselben commonMain-Protokollcode teilen:

RolleAufgabeWichtige Klassen
Sender („Auf den Fernseher casten")Scannt nach Renderern, weist einen an, eine URL abzurufen, steuert die WiedergabeDlnaDeviceScanner, DlnaTransportController, CastPlayer
Empfänger („Drahtloses Casting")Bewirbt sich selbst als MediaRenderer, akzeptiert Steuerung von jedem UPnP-ControllerDlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel

Beide Rollen verwenden DlnaSoap (SOAP-Envelope-Builder) und DlnaDevice (das UPnP-Gerätemodell) aus features/dlna/common/ wieder. Die DLNA-Spezifikation enthält keinerlei Authentifizierung – jeder im LAN, der die Steuerungs-URL eines Renderers kennt, kann ihm Befehle senden. Dies prägt fast jede unten beschriebene Designentscheidung, insbesondere das Vertrauensmodell des Empfängers.

SSDP-Discovery: Geräte ohne Server finden

Die Discovery verwendet SSDP (Simple Service Discovery Protocol), eine dünne Schicht über UDP-Multicast an 239.255.255.250:1900. Es gibt keinen Verzeichnisserver – Geräte kündigen sich selbst an und beantworten Suchanfragen direkt.

Diagram 2
2

Als Sender sendet DlnaDeviceScanner ein M-SEARCH-Datagramm mit Ziel urn:schemas-upnp-org:service:AVTransport:1 und sammelt Unicast-200 OK-Antworten, die jeweils einen LOCATION-Header enthalten, der auf die description.xml des Renderers verweist. Der Scanner dedupliziert nach hostAddress – er parst das Geräte-XML nicht selbst; das überlässt er CastViewModel.searchAsync(), das LOCATION abruft, device.update(xml) aufruft und nur Geräte anzeigt, bei denen device.isAVTransport() true zurückgibt:

fun search(): Flow<DlnaDevice> = searchDlnaDevicesRaw().transform { ssdp ->
    if (devices.none { it.hostAddress == ssdp.hostAddress }) {
        val device = DlnaDevice(ssdp.hostAddress, ssdp.header)
        devices.add(device)
        emit(device)
    }
}

Als Empfänger sendet DlnaReceiverEngine.runSsdpLoop() beim Start drei NOTIFY ssdp:alive-Datagramme (Root-Gerät, MediaRenderer:1-Gerätetyp, AVTransport:1-Diensttyp), kündigt sich alle 30 Sekunden erneut an (CACHE-CONTROL: max-age=1800) und beantwortet eingehende M-SEARCH-Anfragen mit Unicast-Antworten. Bei stop() sendet es sofort ssdp:byebye, anstatt auf den Ablauf des 30-minütigen Caches zu warten – so hört eine TV-Fernbedienungs-App sofort auf, PlainApp aufzulisten, sobald „Drahtloses Casting" deaktiviert wird.

Port-Fallback

Der HTTP-Server des Empfängers versucht zuerst Port 7878, dann 7879, dann 7880, wobei er den zuletzt erfolgreich verwendeten Port bevorzugt:

private val CANDIDATE_PORTS = listOf(7878, 7879, 7880)
private fun openServerSocket(): DlnaServerSocket? {
    val candidates = lastPort
        ?.let { listOf(it) + CANDIDATE_PORTS.filter { p -> p != it } }
        ?: CANDIDATE_PORTS
    for (port in candidates) {
        val ss = createDlnaServerSocket(port)
        if (ss != null) return ss
    }
    return null
}

Wenn alle drei Ports belegt sind (selten, aber möglich, wenn andere DLNA-Apps laufen), wird startError gesetzt und in der Benutzeroberfläche angezeigt, anstatt stillschweigend zu scheitern.

AVTransport-Steuerung: Das SOAP-Protokoll

Sobald ein Renderer gefunden wurde, wird die Wiedergabe über UPnP AVTransport gesteuert – einen SOAP-over-HTTP-Dienst. Jede Aktion ist ein POST an die Steuerungs-URL des Renderers mit einem SOAPAction-Header und einem XML-Body, der in einen SOAP-Envelope gehüllt ist.

Diagram 3
3

DlnaTransportController erstellt jede Anfrage mit einem gemeinsamen Helfer:

private suspend fun executeAVTransportCommand(
    device: DlnaDevice,
    action: String,
    parameters: String = "<InstanceID>0</InstanceID>",
): String {
    val st = device.getAVTransportService()?.serviceType ?: return ""
    return executeSOAPRequest(device, action, "<u:$action xmlns:u=\"$st\">$parameters</u:$action>")
}

executeSOAPRequest setzt SOAPAction: "<serviceType>#<action>" und sendet DlnaSoap.requestEnvelope(soapBody) – dieselben Envelope-Konstanten, die auch auf der Empfängerseite zum Erstellen von Antworten verwendet werden, sodass das Drahtformat nur einmal in commonMain definiert werden muss.

DlnaHttpRouter.handleSoap() auf der Empfängerseite spiegelt dies am anderen Ende wider: Es liest den soapaction-Header, extrahiert den Aktionsnamen nach dem # und leitet entsprechend weiter – SetAVTransportURI, Play, Pause, Stop, Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo, GetDeviceCapabilities. RenderingControl (Lautstärke) wird mit einem statischen 100 gestubbt – PlainApp macht die Gerätelautstärke nicht über UPnP zugänglich.

DIDL-Lite-Metadaten und die Doppelt-Escaping-Eigenart

SetAVTransportURI führt zwei Parameter mit: CurrentURI (die Medien-URL) und CurrentURIMetaData – ein DIDL-Lite-XML-Fragment, das Titel, Medienklasse und Albumcover beschreibt, eingebettet als XML-escapter String innerhalb des äußeren SOAP-Bodys:

private fun buildDidlLiteMetadata(mediaUrl: String, title: String, albumArtUri: String): String {
    val upnpClass = when {
        ext in setOf("mp3", "m4a", "flac", ...) -> "object.item.audioItem.musicTrack"
        ext in setOf("jpg", "jpeg", "png", ...) -> "object.item.imageItem"
        else -> "object.item.videoItem"
    }
    val didl = """<DIDL-Lite xmlns="..."><item id="0" parentID="-1" restricted="0">
        <dc:title>$escapedTitle</dc:title><upnp:class>$upnpClass</upnp:class>$albumArtTag</item></DIDL-Lite>"""
    return didl.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}

Das DIDL-Lite-XML wird zweimal escaped: einmal für den Titeltext selbst (damit ein Song namens Fire & Ice die DIDL-Lite-Tags nicht zerstört) und einmal für das gesamte DIDL-Lite-Dokument (damit seine eigenen </> nicht den äußeren SOAP-Envelope zerstören, in den es als Text eingebettet ist). Dies ist eine bekannte UPnP-Eigenart, kein Fehler – CurrentURIMetaData ist als String-Inhalt definiert, nicht als verschachtelte XML-Elemente.

Auf der Empfängerseite kehrt DlnaSoapHandler dies um: parseSoapAction entschärft den SOAP-Body einmal, um den DIDL-Lite-Text zu erhalten, dann führen extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/extractAlbumArtUriFromDidlMeta jeweils einen zweiten Durchgang der Entity-Entschärfung und Tag-Extraktion auf diesem inneren String durch:

fun extractMediaTypeFromDidlMeta(meta: String, fallbackUri: String = ""): DlnaMediaType {
    val cls = meta.substring(classStart + 12, classEnd).lowercase()
    return when {
        "audioitem" in cls || "musictrack" in cls -> DlnaMediaType.AUDIO
        "imageitem" in cls || "photo" in cls -> DlnaMediaType.IMAGE
        "videoitem" in cls -> DlnaMediaType.VIDEO
        else -> DlnaMediaType.UNKNOWN
    }
}

Wenn <upnp:class> fehlt (mancher Sender lässt es aus), fällt cleanMediaTitle() auf die Dateierweiterung des URI selbst zurück – die Medienart-Erkennung schlägt niemals hart fehl, sondern degradiert lediglich zu UNKNOWN, was als sicheres Standardverhalten an den Videoplayer weitergeleitet wird.

Medienauslieferung an den Fernseher: Range-Requests und DLNA-Header

Ein SetAVTransportURI-Aufruf teilt dem Renderer nur mit, woher er die Medien abrufen soll – die eigentlichen Bytes werden von PlainApps eigenem lokalen HTTP-Server unter /media/{id} ausgeliefert.

Diagram 4
4

UrlHelper.getMediaHttpUrl(path) registriert den tatsächlichen Pfad (der ein content://-URI, eine entfernte URL oder ein einfacher Dateipfad sein kann) unter einer kurzen ID und gibt http://<Geräte-IP>:<Port>/media/<id>.<ext> zurück. Die Route verzweigt dann je nach Art der Quelle:

when {
    path.isUrl() -> call.proxyUrl(path)                 // entfernte URL: Upstream-Antwort streamen
    isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
    path.isImageFast() -> call.respondFile(path)         // Bilder: einfaches statisches Serving
    else -> call.respondDlnaFile(path)                   // Audio/Video: DLNA-bewusstes Serving
}

respondDlnaFile ist der interessante Fall – viele Smart-TVs und DLNA-Renderer weigern sich, einen Stream abzuspielen, wenn er nicht wie eine ordnungsgemäße DLNA-Medien-Server-Antwort aussieht:

override suspend fun respondDlnaFile(path: String): Boolean {
    val file = java.io.File(path)
    if (!file.exists()) return false
    applicationCall.response.run {
        header("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*")
        header("contentFeatures.dlna.org", "")
        header("transferMode.dlna.org", "Streaming")
        header("Connection", "keep-alive")
        header("Server", "DLNADOC/1.50 UPnP/1.0 Plain/1.0 Android/${android.os.Build.VERSION.RELEASE}")
        status(HttpStatusCode.PartialContent) // manche TV-Betriebssysteme akzeptieren nur 206
    }
    applicationCall.respond(LocalFileContent(file))
    return true
}

Beachten Sie, dass der Status immer 206 Partial Content ist, nicht 200 OK – manche TV-Firmware behandelt eine einfache 200-Antwort als „nicht suchbar" und weigert sich, sie abzuspielen, selbst bei einem GET auf die vollständige Datei. Diese eine Statuscode-Entscheidung ist der Unterschied zwischen „läuft einwandfrei" und „Fernseher zeigt endlos einen Ladekreis" auf mehreren realen Geräten.

Dieselbe Route dient auch für Albumcover von gecasteten Audio-Objekten: UrlHelper.getAlbumArtHttpUrl() bildet einen content://media/.../albumart/<id>-URI auf den identischen /media/{id}-Pfad ab, sodass content://-Streaming und DLNA-Dateiauslieferung einen gemeinsamen Codepfad nutzen, unabhängig davon, ob es sich bei den „Medien" um den Song oder sein Coverbild handelt.

GENA-Events: Wiedergabestatus-Callbacks

Nach dem Start der Wiedergabe abonniert der Sender den AVTransport-Eventing-Dienst des Renderers (GENA – General Event Notification Architecture), um ohne Polling über Zustandsänderungen informiert zu werden:

suspend fun subscribeEvent(device: DlnaDevice, callbackUrl: String): String {
    val service = device.getAVTransportService() ?: return ""
    val response = createHttpClient().subscribe(baseUrl + eventSubURL) {
        headers { set("NT", "upnp:event"); set("TIMEOUT", "Second-3600"); set("CALLBACK", "<$callbackUrl>") }
    }
    return response.headers["SID"].orEmpty()
}

SUBSCRIBE/RENEW/UNSUBSCRIBE sind benutzerdefinierte HTTP-Methoden (nicht im Standard-Verbsatz), die über Ktors generischen HttpMethod("SUBSCRIBE")-Request-Builder verarbeitet werden. Der Renderer sendet dann NOTIFY an callbackUrl – PlainApps eigene /callback/cast-Route – sobald sich der Transportzustand, die Position oder die Dauer ändert:

if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
    // zum nächsten Wiedergabelisten-Element vorrücken
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
    CastPlayer.isPlaying.value = true
}

Der Duplikat-Callback-Schutz

Manche Renderer senden für denselben STOPPED-Übergang zwei NOTIFY-Callbacks in schneller Folge – der zweite trägt zufällig AVTransportURIMetaData, während der erste dies nicht tut. Bei beiden das Vorrücken in der Wiedergabeliste auszulösen, würde jedes Mal einen Titel überspringen, wenn die Wiedergabe auf natürliche Weise endet. Die Prüfung !xml.contains("AVTransportURIMetaData") ist ein bewusster, enger Filter: Nur die erste STOPPED-Benachrichtigung (ohne Metadaten) löst das automatische Vorrücken aus. Ein startPositionUpdater()-Job fragt zudem jede Sekunde GetPositionInfo als Fallback ab, da PlainApps eigene SUBSCRIBE-Bestätigung keine Events zurück an andere Controller pusht – nur die Senderseite konsumiert GENA-Callbacks von Fernsehern.

Empfängermodus: Ein UPnP-MediaRenderer werden

Drehen wir die Richtung um: Jeder DLNA-Controller (eine TV-Fernbedienungs-App, VLC, eine andere PlainApp) kann Medien auf das Telefon selbst pushen. DlnaReceiverEngine öffnet denselben HTTP- und SSDP-Server wie oben beschrieben, diesmal jedoch als der gesteuerte MediaRenderer und nicht als der Controller.

DlnaHttpRouter.route() liefert description.xml (erstellt von DlnaXmlTemplates.deviceDescription(), die einen AVTransport-Dienst und einen gestubbten RenderingControl-Dienst auflistet) und leitet SOAP-Aktionen an DlnaSoapHandler weiter. Ein SetAVTransportURI-Aufruf startet nicht sofort die Wiedergabe – er speichert einen PendingCastRequest und wartet:

if (uri.isNotEmpty()) {
    DlnaRendererState.rawPendingCastRequest.value =
        PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)
    DlnaRendererState.pendingPlayQueued.value = false
}

Ein Play, das eintrifft, bevor die ausstehende Anfrage aufgelöst ist, startet ebenfalls nicht die Wiedergabe – es setzt pendingPlayQueued = true, sodass der Befehl automatisch wiederholt wird, sobald die Cast-Anfrage akzeptiert wird, anstatt stillschweigend verloren zu gehen:

val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||
    DlnaRendererState.pendingCastRequest.value != null
if (hasPending) {
    DlnaRendererState.pendingPlayQueued.value = true
} else {
    DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)
}

Akzeptierte Befehle fließen durch einen einzigen Channel<DlnaCommand>, den DlnaReceiverViewModel abarbeitet, wodurch die rohe Socket-Verarbeitungs-Koroutine vom UI/Player-Zustand entkoppelt wird – der HTTP-Handler berührt ExoPlayer niemals direkt. Die Wiedergabe wird dann je nach DlnaMediaType an eines von drei Vollbild-Composables weitergeleitet: DlnaReceiverAudioPlayerContent (Verlaufs-Hintergrund, Albumcover, Suchleiste), einen Bildbetrachter oder DlnaReceiverVideoPlayerContent (ExoPlayer).

Sicherheit: Sender-Vertrauen per Allow/Deny-Listen

DLNA hat keine Authentifizierung – jedes Gerät im LAN kann einem Renderer ein SetAVTransportURI senden. Ein persönliches Telefon in einen unauthentifizierten MediaRenderer zu verwandeln, würde es jedem im selben WLAN (einem gemeinsamen Büronetzwerk, dem Haus eines Freundes, einem feindlichen GästewLAN) ermöglichen, beliebige Medien-URLs darauf zu pushen. PlainApp schließt diese Lücke mit einer vertrauensbasierten Liste pro Sender-IP, die jede eingehende Cast-Anfrage absichert:

Diagram 5
5

DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
    val allowed = DlnaAllowedSendersPreference.getAsync()
    val denied = DlnaDeniedSendersPreference.getAsync()
    when {
        DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
            // Automatisch akzeptieren: Befehle direkt senden, ohne Dialog anzuzeigen
        }
        DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
            // Automatisch ablehnen: stillschweigend verwerfen
        }
        else -> {
            // Unbekannter Sender: in UI-sichtbaren Zustand überführen, damit der Benutzer entscheiden kann
            DlnaRendererState.pendingCastRequest.value = pending
        }
    }
}

Die Cast-Anfrage eines unbekannten Senders zeigt einen Bestätigungsdialog mit einem optionalen „Diese Entscheidung merken"-Häkchen an; wird „Merken" gewählt, wird die IP des Senders in die Allow- oder Deny-Einstellung geschrieben, sodass zukünftige Anfragen von derselben Adresse den Dialog überspringen. Dies wird vollständig in DlnaReceiverViewModel oberhalb des Drahtprotokolls durchgesetzt – der SOAP-Handler selbst gibt immer 200 OK zurück, unabhängig von der Vertrauensentscheidung (gemäß UPnP-Spezifikation war der Transportaufruf erfolgreich; ob die Medien tatsächlich abgespielt werden, ist eine separate, lokale Entscheidung).

Plattformaufteilung: commonMain-Orchestrierung, androidMain-Sockets

Diagram 6
6

Dem gleichen Muster folgend wie die anderen Netzwerkfunktionen von PlainApp, ist nur die rohe Byte-Ebene der Socket-I/O plattformspezifisch. DlnaServerSocket und DlnaSsdpSocket sind expect-Schnittstellen; die actual-Implementierungen für Android kapseln java.net.ServerSocket und java.net.MulticastSocket direkt. Alles andere – Port-Auswahl, der SSDP-alive/byebye-Rhythmus, HTTP-Routing, SOAP-Parsing, DIDL-Lite-Metadaten und alle vier DlnaCommand-Zustandsübergänge – lebt in commonMain und ist auf der JVM ohne Android-Gerät unit-testbar.

iOS erhält SOAP-Steuerung auf der Senderseite (es ist ein einfacher HTTP-Client, es müssen keine Sockets implementiert werden), aber der Empfänger ist bewusst als No-Op implementiert:

actual fun startDlnaRenderer() {}

iOS bietet keine Möglichkeit, einen Hintergrund-UDP-Multicast-Listener zuverlässig genug auszuführen, um ein guter MediaRenderer-Bürger zu sein – daher ist „Drahtloses Casting" (Empfangen) nur auf Android verfügbar; „Auf den Fernseher casten" (Senden) funktioniert auf beiden Plattformen.

Pure-Kotlin-Engineering-Notizen

Einige Implementierungsdetails dienen speziell dazu, plattformabhängige Abhängigkeiten zu vermeiden, die die Kotlin-Multiplatform-Freigabe brechen würden:

  • UUID-v4-Erzeugung. DlnaReceiverEngine.randomUuid() erstellt von Hand eine RFC-4122-v4-UUID aus Random.nextBytes(16) anstelle von java.util.UUID.randomUUID(), sodass der Geräteidentitätsgenerator auf jeder Plattform identisch ist.
  • Prozent-Decodierung. Die private Funktion percentDecode() von DlnaSoapHandler ersetzt java.net.URLDecoder.decode() für Titel, die URL-kodiert in einem Medien-URI ankommen.
  • Byte-genaues Lesen des HTTP-Bodys. Content-Length ist eine Byte-Anzahl, keine Zeichenanzahl. AndroidDlnaClientConnection.readHttpRequest() liest den Body über readBodyBytes(bis, contentLength) gegen den rohen BufferedInputStream, niemals über einen BufferedReader/CharArray – ein Titel mit Multi-Byte-UTF-8 (z. B. chinesische Zeichen, je 3 Byte) würde sonst den Body zu kurz lesen und auf bereits angekommene Bytes warten, wodurch SetAVTransportURI für nicht-ASCII-Titel stillschweigend brechen würde.
  • Basis-URL-Parsing ohne java.net.URL. DlnaDevice.getBaseUrl() extrahiert Schema/Host/Port aus einem LOCATION-Header mit einfachen String-Slicing-Operationen (substringAfter("://"), substringBefore('/')) anstatt ein java.net.URL zu konstruieren.

Entwurfsmuster-Rückblick

MusterWoWarum
Geteiltes Protokoll, geteilte I/ODlnaSoap/DlnaXmlTemplates in commonMain, Sockets in androidMainDas Drahtformat wird einmal definiert, die Plattform liefert nur Bytes rein/raus
Ausstehend → Regelprüfung → HochstufenrawPendingCastRequestpendingCastRequestTrennt „eine Anfrage ist eingetroffen" von „eine Anfrage benötigt eine menschliche Entscheidung"
Befehls-Warteschlangen-EntkopplungChannel<DlnaCommand>Die HTTP-Handler-Koroutine berührt ExoPlayer/UI-Zustand niemals direkt
Warteschlangen-Befehls-WiederholungpendingPlayQueuedEin Play, das der Genehmigung von SetAVTransportURI zuvorkommt, wird nicht verworfen
Bewusster StatuscoderespondDlnaFile → immer 206Entspricht dem, was echte TV-Firmware erwartet, nicht nur dem spezifikationsminimalen 200
Enger Duplikatfilter!xml.contains("AVTransportURIMetaData")Unterscheidet die beiden STOPPED-Callbacks einiger Renderer ohne generischen Dedup-Mechanismus
Sofortiges Byebyestop() sendet ssdp:byebye vor dem AbbrechenVermeidet einen veralteten 30-minütigen SSDP-Cache-Eintrag, nachdem der Benutzer die Funktion deaktiviert hat
Fail-Open bei Unbekannt, Fail-Closed standardmäßigAllow/Deny-Präferenz + DialogWeder automatisches Vertrauen noch automatisches Blockieren eines Erst-Senders – ein Mensch entscheidet einmal

Weiterführende Literatur

  • UPnP Device Architecture – die zugrundeliegende SSDP/GENA/SOAP-Spezifikation.
  • Für die auf WebSocket basierende Bildschirmspiegelung mit niedriger Latenz (ein anderer, nicht-DLNA-Casting-Pfad) siehe Screen Mirror.