package de.duffy.screencast import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.hardware.display.DisplayManager import android.hardware.display.VirtualDisplay import android.media.projection.MediaProjection import android.media.projection.MediaProjectionManager import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.PowerManager import android.util.DisplayMetrics import android.util.Log import android.view.WindowManager import androidx.core.app.NotificationCompat import de.duffy.screencast.miracast.MiracastPipeline import de.duffy.screencast.miracast.WfdFormats import java.net.InetAddress /** * Haelt die Bildschirmaufnahme am Laufen: Aufnahme -> Encoder -> Server. * Laeuft als Vordergrunddienst, damit Android die Uebertragung nicht killt, * wenn der Nutzer die App verlaesst. */ class CastService : Service(), ScreenEncoder.Listener { private var projection: MediaProjection? = null private var virtualDisplay: VirtualDisplay? = null private var encoder: ScreenEncoder? = null private var server: WebServer? = null private var wakeLock: PowerManager.WakeLock? = null private val hub = StreamHub() private val handler = Handler(Looper.getMainLooper()) private var quality = Quality.HD private var frameRate = 30 private var portraitMode = true private var mode = MODE_BROWSER private var miracast: MiracastPipeline? = null private val projectionCallback = object : MediaProjection.Callback() { override fun onStop() { Log.i(TAG, "Aufnahme wurde vom System beendet") handler.post { stopCasting() } } } private val displayListener = object : DisplayManager.DisplayListener { override fun onDisplayAdded(displayId: Int) {} override fun onDisplayRemoved(displayId: Int) {} override fun onDisplayChanged(displayId: Int) { if (displayId != 0) return val metrics = screenMetrics() val nowPortrait = metrics.heightPixels >= metrics.widthPixels if (nowPortrait != portraitMode && projection != null) { portraitMode = nowPortrait handler.post { restartPipeline() } } } } override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_STOP -> { stopCasting(); return START_NOT_STICKY } ACTION_SINK_READY -> { // Die Wi-Fi-Direct-Gruppe steht: zusaetzlich in Gegenrichtung probieren. val address = intent.getStringExtra(EXTRA_SINK_ADDRESS) val port = intent.getIntExtra(EXTRA_SINK_PORT, 7236) if (!address.isNullOrEmpty()) { CastState.appendLog("Fernseher erreichbar unter $address") miracast?.tryOutgoing(InetAddress.getByName(address), port) } } ACTION_START -> startCasting(intent) } return START_NOT_STICKY } private fun startCasting(intent: Intent) { if (projection != null) return quality = Quality.entries.getOrElse(intent.getIntExtra(EXTRA_QUALITY, 1)) { Quality.HD } frameRate = intent.getIntExtra(EXTRA_FPS, 30) mode = intent.getStringExtra(EXTRA_MODE) ?: MODE_BROWSER // Auf Android 14+ muss der Dienst im Vordergrund sein, bevor die // MediaProjection geholt werden darf. startForegroundNotification("Wird gestartet ...") val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0) val data: Intent? = if (Build.VERSION.SDK_INT >= 33) { intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java) } else { @Suppress("DEPRECATION") intent.getParcelableExtra(EXTRA_RESULT_DATA) } if (data == null) { stopCasting(); return } val manager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager val mediaProjection = manager.getMediaProjection(resultCode, data) if (mediaProjection == null) { CastState.error.postValue("Bildschirmaufnahme wurde nicht erlaubt") stopCasting(); return } mediaProjection.registerCallback(projectionCallback, handler) projection = mediaProjection val power = getSystemService(Context.POWER_SERVICE) as PowerManager wakeLock = power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "screencast:stream").apply { setReferenceCounted(false); acquire(4 * 60 * 60 * 1000L) } if (mode == MODE_MIRACAST) { startMiracast(intent) return } try { server = WebServer({ name -> assets.open(name).use { it.readBytes() } }, hub) .also { it.start() } } catch (e: Exception) { Log.e(TAG, "Server-Start fehlgeschlagen", e) CastState.error.postValue("Server konnte nicht starten: ${e.message}") stopCasting(); return } hub.onKeyFrameNeeded = { encoder?.requestKeyFrame() } val metrics = screenMetrics() portraitMode = metrics.heightPixels >= metrics.widthPixels (getSystemService(Context.DISPLAY_SERVICE) as DisplayManager) .registerDisplayListener(displayListener, handler) startPipeline() val url = "http://${NetworkUtil.localAddress(this)}:${server?.port ?: 8080}" CastState.url.postValue(url) CastState.running.postValue(true) CastState.error.postValue(null) startForegroundNotification(url) scheduleViewerUpdates() } /** Direktweg: Bild ueber Wi-Fi Direct an den wartenden Fernseher schicken. */ private fun startMiracast(intent: Intent) { val address = intent.getStringExtra(EXTRA_SINK_ADDRESS) val port = intent.getIntExtra(EXTRA_SINK_PORT, 7236) val projection = this.projection ?: run { stopCasting(); return } val pipeline = MiracastPipeline( projection = projection, densityDpi = screenMetrics().densityDpi, handler = handler, maxHeight = quality.longSide.coerceAtMost(1080), bitrate = quality.bitrate, listener = object : MiracastPipeline.Listener { override fun onStatus(text: String) { Log.i(TAG, "Miracast: $text") CastState.appendLog(text) } override fun onStreaming(format: WfdFormats.VideoFormat) { CastState.running.postValue(true) CastState.resolution.postValue("$format über Wi-Fi Direct") CastState.viewers.postValue(1) CastState.appendLog("Übertragung läuft: $format") startForegroundNotification("Direkt am Fernseher · $format") } override fun onFinished(reason: String) { CastState.appendLog(reason) CastState.error.postValue(reason) handler.post { stopCasting() } } } ) miracast = pipeline CastState.running.postValue(true) CastState.url.postValue("") CastState.error.postValue(null) startForegroundNotification("Warte auf den Fernseher ...") // Zuerst zuhoeren: Beim Wi-Fi-Display-Standard ruft der Fernseher an. pipeline.startListening() if (!address.isNullOrEmpty()) { pipeline.tryOutgoing(InetAddress.getByName(address), port) } // Falls sich gar nichts tut, nach einer Weile Bescheid sagen. handler.postDelayed({ if (miracast === pipeline && CastState.viewers.value == 0) { CastState.appendLog( "Der Fernseher hat sich nicht gemeldet. Manche Geräte verlangen, " + "dass sich das Handy als Miracast-Quelle ausweist - das ist " + "Systemapps vorbehalten. Dann hilft nur der Weg über den " + "Übertragungsdienst von Android oder der Browser." ) } }, 30_000) } /** Encoder und VirtualDisplay aufsetzen - passend zur aktuellen Ausrichtung. */ private fun startPipeline() { val metrics = screenMetrics() val (width, height) = fitSize(metrics.widthPixels, metrics.heightPixels, quality.longSide) val enc = ScreenEncoder(width, height, quality.bitrate, frameRate, this) enc.start() encoder = enc virtualDisplay = projection?.createVirtualDisplay( "SmartScreenCast", width, height, metrics.densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC, enc.surface, null, handler ) CastState.resolution.postValue("${width}x$height @ ${frameRate}fps") Log.i(TAG, "Uebertragung laeuft mit ${width}x$height") } /** Nach dem Drehen des Geraets neu aufsetzen, damit das Bild passt. */ private fun restartPipeline() { if (projection == null) return Log.i(TAG, "Ausrichtung geaendert - Encoder wird neu gestartet") virtualDisplay?.release(); virtualDisplay = null encoder?.stop(); encoder = null startPipeline() } private fun stopCasting() { try { (getSystemService(Context.DISPLAY_SERVICE) as DisplayManager) .unregisterDisplayListener(displayListener) } catch (_: Exception) {} miracast?.stop(); miracast = null hub.closeAll() server?.stop(); server = null virtualDisplay?.release(); virtualDisplay = null encoder?.stop(); encoder = null projection?.unregisterCallback(projectionCallback) projection?.stop(); projection = null wakeLock?.let { if (it.isHeld) it.release() } wakeLock = null CastState.running.postValue(false) CastState.viewers.postValue(0) CastState.url.postValue("") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE) } else { @Suppress("DEPRECATION") stopForeground(true) } stopSelf() } override fun onDestroy() { super.onDestroy() if (projection != null) stopCasting() } // ------------------------------------------------------- Encoder-Rueckruf override fun onConfig(sps: ByteArray, pps: ByteArray, width: Int, height: Int) { hub.onEncoderConfig(sps, pps, width, height) } override fun onFrame(nalUnits: List, ptsUs: Long, keyframe: Boolean) { hub.onFrame(nalUnits, ptsUs, keyframe) } // -------------------------------------------------------------- Hilfen private fun scheduleViewerUpdates() { handler.post(object : Runnable { override fun run() { if (projection == null) return val count = hub.viewerCount if (CastState.viewers.value != count) CastState.viewers.postValue(count) handler.postDelayed(this, 1000) } }) } @Suppress("DEPRECATION") private fun screenMetrics(): DisplayMetrics { val metrics = DisplayMetrics() val wm = getSystemService(Context.WINDOW_SERVICE) as WindowManager wm.defaultDisplay.getRealMetrics(metrics) return metrics } private fun startForegroundNotification(text: String) { val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { manager.createNotificationChannel( NotificationChannel(CHANNEL_ID, "Bildschirmuebertragung", NotificationManager.IMPORTANCE_LOW) .apply { setShowBadge(false) } ) } val openApp = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val stop = PendingIntent.getService( this, 1, Intent(this, CastService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Bildschirm wird uebertragen") .setContentText(text) .setSmallIcon(R.drawable.ic_cast) .setOngoing(true) .setContentIntent(openApp) .addAction(R.drawable.ic_cast, "Beenden", stop) .setPriority(NotificationCompat.PRIORITY_LOW) .build() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) } else { startForeground(NOTIFICATION_ID, notification) } } /** Passt die Aufloesung an die Zielgroesse an, auf Vielfache von 16 gerundet. */ private fun fitSize(width: Int, height: Int, longSide: Int): Pair { val scale = longSide.toFloat() / maxOf(width, height) val w = if (scale < 1f) (width * scale).toInt() else width val h = if (scale < 1f) (height * scale).toInt() else height return Pair(align(w), align(h)) } private fun align(value: Int): Int = ((value + 8) / 16) * 16 enum class Quality(val longSide: Int, val bitrate: Int, val label: String) { SD(854, 2_500_000, "Sparsam - 480p"), HD(1280, 6_000_000, "Ausgewogen - 720p"), FHD(1920, 12_000_000, "Scharf - 1080p") } companion object { private const val TAG = "CastService" private const val CHANNEL_ID = "screencast" private const val NOTIFICATION_ID = 42 const val ACTION_START = "de.duffy.screencast.START" const val ACTION_STOP = "de.duffy.screencast.STOP" const val EXTRA_RESULT_CODE = "resultCode" const val EXTRA_RESULT_DATA = "resultData" const val EXTRA_QUALITY = "quality" const val EXTRA_FPS = "fps" const val EXTRA_MODE = "mode" const val EXTRA_SINK_ADDRESS = "sinkAddress" const val EXTRA_SINK_PORT = "sinkPort" const val ACTION_SINK_READY = "de.duffy.screencast.SINK_READY" const val MODE_BROWSER = "browser" const val MODE_MIRACAST = "miracast" } }