1
0
Fork 0
mirror of https://gitlab.com/futo-org/fcast.git synced 2025-06-24 21:25:23 +00:00

Added websocket wrapper for TCP connection to Android receiver.

This commit is contained in:
Koen 2023-12-06 09:04:14 +01:00
parent b339f4f487
commit ad8f3985a3
22 changed files with 1165 additions and 277 deletions

View file

@ -85,6 +85,7 @@ dependencies {
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
implementation "com.squareup.okhttp3:okhttp:4.11.0"
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'org.java-websocket:Java-WebSocket:1.5.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

View file

@ -54,7 +54,7 @@
</receiver>
<service
android:name=".TcpListenerService"
android:name=".NetworkService"
android:enabled="true"
android:exported="false" />

View file

@ -22,7 +22,7 @@ class BootReceiver : BroadcastReceiver() {
showStartServiceNotification(context);
} else {
// Directly start the service for older versions
val serviceIntent = Intent(context, TcpListenerService::class.java)
val serviceIntent = Intent(context, NetworkService::class.java)
context.startService(serviceIntent)
}
}
@ -53,7 +53,7 @@ class BootReceiver : BroadcastReceiver() {
}
// PendingIntent to start the TcpListenerService
val serviceIntent = Intent(context, TcpListenerService::class.java)
val serviceIntent = Intent(context, NetworkService::class.java)
val pendingIntent = PendingIntent.getService(context, 0, serviceIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val startServiceAction = NotificationCompat.Action.Builder(0, "Start Service", pendingIntent).build()

View file

@ -1,5 +1,6 @@
package com.futo.fcast.receiver
import WebSocketListenerService
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
@ -7,7 +8,8 @@ import android.util.Log
class DiscoveryService(private val _context: Context) {
private var _nsdManager: NsdManager? = null
private val _serviceType = "_fcast._tcp"
private val _registrationListenerTcp = DefaultRegistrationListener()
private val _registrationListenerWs = DefaultRegistrationListener()
private fun getDeviceName(): String {
return "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}"
@ -20,23 +22,28 @@ class DiscoveryService(private val _context: Context) {
Log.i("DiscoveryService", "Discovery service started. Name: $serviceName")
_nsdManager = _context.getSystemService(Context.NSD_SERVICE) as NsdManager
val serviceInfo = NsdServiceInfo().apply {
_nsdManager?.registerService(NsdServiceInfo().apply {
this.serviceName = serviceName
this.serviceType = _serviceType
this.port = 46899
}
this.serviceType = "_fcast._tcp"
this.port = TcpListenerService.PORT
}, NsdManager.PROTOCOL_DNS_SD, _registrationListenerTcp)
_nsdManager?.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
_nsdManager?.registerService(NsdServiceInfo().apply {
this.serviceName = serviceName
this.serviceType = "_fcast._ws"
this.port = WebSocketListenerService.PORT
}, NsdManager.PROTOCOL_DNS_SD, _registrationListenerWs)
}
fun stop() {
if (_nsdManager == null) return
_nsdManager?.unregisterService(registrationListener)
_nsdManager?.unregisterService(_registrationListenerTcp)
_nsdManager?.unregisterService(_registrationListenerWs)
_nsdManager = null
}
private val registrationListener = object : NsdManager.RegistrationListener {
private class DefaultRegistrationListener : NsdManager.RegistrationListener {
override fun onServiceRegistered(serviceInfo: NsdServiceInfo) {
Log.d("DiscoveryService", "Service registered: ${serviceInfo.serviceName}")
}

View file

@ -4,9 +4,10 @@ import android.util.Log
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.OutputStream
import java.net.Socket
import java.net.SocketAddress
import java.nio.ByteBuffer
enum class SessionState {
@ -31,12 +32,12 @@ enum class Opcode(val value: Byte) {
const val LENGTH_BYTES = 4
const val MAXIMUM_PACKET_LENGTH = 32000
class FCastSession(private val _socket: Socket, private val _service: TcpListenerService) {
class FCastSession(outputStream: OutputStream, private val _remoteSocketAddress: SocketAddress, private val _service: NetworkService) {
private var _buffer = ByteArray(MAXIMUM_PACKET_LENGTH)
private var _bytesRead = 0
private var _packetLength = 0
private var _state = SessionState.WaitingForLength
private var _outputStream: DataOutputStream? = DataOutputStream(_socket.outputStream)
private var _outputStream: DataOutputStream? = DataOutputStream(outputStream)
fun sendPlaybackUpdate(value: PlaybackUpdateMessage) {
send(Opcode.PlaybackUpdate, value)
@ -82,6 +83,24 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene
Log.d(TAG, "Sent $size bytes: '$jsonString'.")
} catch (e: Throwable) {
Log.i(TAG, "Failed to send message.", e)
throw e
}
}
fun processBytes(data: ByteBuffer) {
Log.i(TAG, "${data.remaining()} bytes received from ${_remoteSocketAddress}")
if (!data.hasArray()) {
throw IllegalArgumentException("ByteBuffer does not have a backing array")
}
val byteArray = data.array()
val offset = data.arrayOffset() + data.position()
val length = data.remaining()
when (_state) {
SessionState.WaitingForLength -> handleLengthBytes(byteArray, offset, length)
SessionState.WaitingForData -> handlePacketBytes(byteArray, offset, length)
else -> throw Exception("Invalid state $_state encountered")
}
}
@ -90,7 +109,7 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene
return
}
Log.i(TAG, "$count bytes received from ${_socket.remoteSocketAddress}")
Log.i(TAG, "$count bytes received from ${_remoteSocketAddress}")
when (_state) {
SessionState.WaitingForLength -> handleLengthBytes(data, 0, count)
@ -116,17 +135,15 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene
((_buffer[3].toInt() and 0xff) shl 24)
_bytesRead = 0
Log.i(TAG, "Packet length header received from ${_socket.remoteSocketAddress}: $_packetLength")
Log.i(TAG, "Packet length header received from ${_remoteSocketAddress}: $_packetLength")
if (_packetLength > MAXIMUM_PACKET_LENGTH) {
Log.i(TAG, "Maximum packet length is 32kB, killing socket ${_socket.remoteSocketAddress}: $_packetLength")
_socket.close()
_state = SessionState.Disconnected
return
Log.i(TAG, "Maximum packet length is 32kB, killing socket ${_remoteSocketAddress}: $_packetLength")
throw Exception("Maximum packet length is 32kB")
}
if (bytesRemaining > 0) {
Log.i(TAG, "$bytesRemaining remaining bytes ${_socket.remoteSocketAddress} pushed to handlePacketBytes")
Log.i(TAG, "$bytesRemaining remaining bytes ${_remoteSocketAddress} pushed to handlePacketBytes")
handlePacketBytes(data, offset + bytesToRead, bytesRemaining)
}
}
@ -141,7 +158,7 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene
Log.i(TAG, "Read $bytesToRead bytes from packet")
if (_bytesRead >= _packetLength) {
Log.i(TAG, "Packet finished receiving from ${_socket.remoteSocketAddress} of $_packetLength bytes.")
Log.i(TAG, "Packet finished receiving from ${_remoteSocketAddress} of $_packetLength bytes.")
handlePacket()
_state = SessionState.WaitingForLength
@ -149,14 +166,14 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene
_bytesRead = 0
if (bytesRemaining > 0) {
Log.i(TAG, "$bytesRemaining remaining bytes ${_socket.remoteSocketAddress} pushed to handleLengthBytes")
Log.i(TAG, "$bytesRemaining remaining bytes ${_remoteSocketAddress} pushed to handleLengthBytes")
handleLengthBytes(data, offset + bytesToRead, bytesRemaining)
}
}
}
private fun handlePacket() {
Log.i(TAG, "Processing packet of $_bytesRead bytes from ${_socket.remoteSocketAddress}")
Log.i(TAG, "Processing packet of $_bytesRead bytes from ${_remoteSocketAddress}")
val opcode = Opcode.values().firstOrNull { it.value == _buffer[0] } ?: Opcode.None
val body = if (_packetLength > 1) _buffer.copyOfRange(1, _packetLength)

View file

@ -1,5 +1,6 @@
package com.futo.fcast.receiver
import WebSocketListenerService
import android.Manifest
import android.app.AlertDialog
import android.app.PendingIntent
@ -102,7 +103,7 @@ class MainActivity : AppCompatActivity() {
_lastDemoToast?.cancel()
_lastDemoToast = Toast.makeText(this, "Click $remainingClicks more times to start demo", Toast.LENGTH_SHORT).apply { show() }
} else if (_demoClickCount == 5) {
TcpListenerService.instance?.onCastPlay(PlayMessage("video/mp4", "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"))
NetworkService.instance?.onCastPlay(PlayMessage("video/mp4", "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"))
_demoClickCount = 0
}
}
@ -123,16 +124,18 @@ class MainActivity : AppCompatActivity() {
}
val ips = getIPs()
_textIPs.text = "IPs\n" + ips.joinToString("\n") + "\n\nPort\n46899"
_textIPs.text = "IPs\n" + ips.joinToString("\n") + "\n\nPorts\n${TcpListenerService.PORT} (TCP), ${WebSocketListenerService.PORT} (WS)"
try {
val barcodeEncoder = BarcodeEncoder()
val px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 100.0f, resources.displayMetrics).toInt()
val json = Json.encodeToString(FCastNetworkConfig(ips, listOf(
FCastService(46899, 0)
val json = Json.encodeToString(FCastNetworkConfig("${Build.MANUFACTURER}-${Build.MODEL}", ips, listOf(
FCastService(TcpListenerService.PORT, 0),
FCastService(WebSocketListenerService.PORT, 1)
)))
val base64 = Base64.encode(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
val url = "fcast://r/${base64}"
Log.i(TAG, "connection url: $url")
val bitmap = barcodeEncoder.encodeBitmap(url, BarcodeFormat.QR_CODE, px, px)
_imageQr.setImageBitmap(bitmap)
} catch (e: java.lang.Exception) {
@ -140,7 +143,7 @@ class MainActivity : AppCompatActivity() {
_imageQr.visibility = View.GONE
}
TcpListenerService.activityCount++
NetworkService.activityCount++
checkAndRequestPermissions()
if (savedInstanceState == null) {
@ -167,7 +170,7 @@ class MainActivity : AppCompatActivity() {
InstallReceiver.onReceiveResult = null
_scope.cancel()
_player.release()
TcpListenerService.activityCount--
NetworkService.activityCount--
}
override fun onSaveInstanceState(outState: Bundle) {
@ -176,12 +179,12 @@ class MainActivity : AppCompatActivity() {
}
private fun restartService() {
val i = TcpListenerService.instance
val i = NetworkService.instance
if (i != null) {
i.stopSelf()
}
startService(Intent(this, TcpListenerService::class.java))
startService(Intent(this, NetworkService::class.java))
}
private fun startVideo() {
@ -535,7 +538,8 @@ class MainActivity : AppCompatActivity() {
continue
}
Log.i(TcpListenerService.TAG, "Running on ${addr.hostAddress}:${TcpListenerService.PORT}")
Log.i(TAG, "Running on ${addr.hostAddress}:${TcpListenerService.PORT} (TCP)")
Log.i(TAG, "Running on ${addr.hostAddress}:${WebSocketListenerService.PORT} (WebSocket)")
addr.hostAddress?.let { ips.add(it) }
}
}

View file

@ -4,7 +4,8 @@ import kotlinx.serialization.Serializable
@Serializable
data class FCastNetworkConfig(
val ips: List<String>,
val name: String,
val addresses: List<String>,
val services: List<FCastService>
)

View file

@ -0,0 +1,285 @@
package com.futo.fcast.receiver
import WebSocketListenerService
import android.app.*
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.*
class NetworkService : Service() {
private var _discoveryService: DiscoveryService? = null
private var _tcpListenerService: TcpListenerService? = null
private var _webSocketListenerService: WebSocketListenerService? = null
private var _scope: CoroutineScope? = null
private var _stopped = false
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (instance != null) {
throw Exception("Do not start service when already running")
}
instance = this
Log.i(TAG, "Starting ListenerService")
_scope = CoroutineScope(Dispatchers.Main)
_stopped = false
val name = "Network Listener Service"
val descriptionText = "Listening on port ${TcpListenerService.PORT} (TCP) and port ${WebSocketListenerService.PORT} (Websocket)"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
description = descriptionText
}
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
val notification: Notification = createNotificationBuilder()
.setContentTitle(name)
.setContentText(descriptionText)
.setSmallIcon(R.mipmap.ic_launcher)
.build()
startForeground(NOTIFICATION_ID, notification)
val onNewSession: (FCastSession) -> Unit = { session ->
_scope?.launch(Dispatchers.Main) {
var encounteredError = false
while (!_stopped && !encounteredError) {
try {
val player = PlayerActivity.instance
val updateMessage = if (player != null) {
PlaybackUpdateMessage(
player.currentPosition / 1000.0,
player.duration / 1000.0,
if (player.isPlaying) 1 else 2
)
} else {
PlaybackUpdateMessage(
0.0,
0.0,
0
)
}
withContext(Dispatchers.IO) {
try {
session.sendPlaybackUpdate(updateMessage)
} catch (eSend: Throwable) {
Log.e(TAG, "Unhandled error sending update", eSend)
encounteredError = true
return@withContext
}
Log.i(TAG, "Update sent")
}
} catch (eTimer: Throwable) {
Log.e(TAG, "Unhandled error on timer thread", eTimer)
} finally {
delay(1000)
}
}
}
}
_discoveryService = DiscoveryService(this).apply {
start()
}
_tcpListenerService = TcpListenerService(this, onNewSession).apply {
start()
}
_webSocketListenerService = WebSocketListenerService(this, onNewSession).apply {
start()
}
Log.i(TAG, "Started NetworkService")
Toast.makeText(this, "Started FCast service", Toast.LENGTH_LONG).show()
return START_STICKY
}
private fun createNotificationBuilder(): NotificationCompat.Builder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationCompat.Builder(this, CHANNEL_ID)
} else {
// For pre-Oreo, do not specify the channel ID
NotificationCompat.Builder(this)
}
}
override fun onDestroy() {
super.onDestroy()
Log.i(TAG, "Stopped NetworkService")
_stopped = true
_discoveryService?.stop()
_discoveryService = null
_tcpListenerService?.stop()
_tcpListenerService = null
try {
_webSocketListenerService?.stop()
} catch (e: Throwable) {
//Ignored
} finally {
_webSocketListenerService = null
}
_scope?.cancel()
_scope = null
Toast.makeText(this, "Stopped FCast service", Toast.LENGTH_LONG).show()
instance = null
}
fun sendCastVolumeUpdate(value: VolumeUpdateMessage) {
_tcpListenerService?.forEachSession { session ->
_scope?.launch {
try {
session.sendVolumeUpdate(value)
} catch (e: Throwable) {
Log.w(TAG, "Failed to send volume update", e)
}
}
}
_webSocketListenerService?.forEachSession { session ->
_scope?.launch {
try {
session.sendVolumeUpdate(value)
} catch (e: Throwable) {
Log.w(TAG, "Failed to send volume update", e)
}
}
}
}
fun onCastPlay(playMessage: PlayMessage) {
Log.i(TAG, "onPlay")
_scope?.launch {
try {
if (PlayerActivity.instance == null) {
val i = Intent(this@NetworkService, PlayerActivity::class.java)
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
i.putExtra("container", playMessage.container)
i.putExtra("url", playMessage.url)
i.putExtra("content", playMessage.content)
i.putExtra("time", playMessage.time)
if (activityCount > 0) {
startActivity(i)
} else if (Settings.canDrawOverlays(this@NetworkService)) {
val pi = PendingIntent.getActivity(this@NetworkService, 0, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
pi.send()
} else {
val pi = PendingIntent.getActivity(this@NetworkService, 0, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val playNotification = createNotificationBuilder()
.setContentTitle("FCast")
.setContentText("New content received. Tap to play.")
.setSmallIcon(R.drawable.ic_launcher_background)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.build()
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(PLAY_NOTIFICATION_ID, playNotification)
}
} else {
PlayerActivity.instance?.play(playMessage)
}
} catch (e: Throwable) {
Log.e(TAG, "Failed to play", e)
}
}
}
fun onCastPause() {
Log.i(TAG, "onPause")
_scope?.launch {
try {
PlayerActivity.instance?.pause()
} catch (e: Throwable) {
Log.e(TAG, "Failed to pause", e)
}
}
}
fun onCastResume() {
Log.i(TAG, "onResume")
_scope?.launch {
try {
PlayerActivity.instance?.resume()
} catch (e: Throwable) {
Log.e(TAG, "Failed to resume", e)
}
}
}
fun onCastStop() {
Log.i(TAG, "onStop")
_scope?.launch {
try {
PlayerActivity.instance?.finish()
} catch (e: Throwable) {
Log.e(TAG, "Failed to stop", e)
}
}
}
fun onCastSeek(seekMessage: SeekMessage) {
Log.i(TAG, "onSeek")
_scope?.launch {
try {
PlayerActivity.instance?.seek(seekMessage)
} catch (e: Throwable) {
Log.e(TAG, "Failed to seek", e)
}
}
}
fun onSetVolume(setVolumeMessage: SetVolumeMessage) {
Log.i(TAG, "onSetVolume")
_scope?.launch {
try {
PlayerActivity.instance?.setVolume(setVolumeMessage)
} catch (e: Throwable) {
Log.e(TAG, "Failed to seek", e)
}
}
}
companion object {
private const val CHANNEL_ID = "NetworkListenerServiceChannel"
private const val NOTIFICATION_ID = 1
private const val PLAY_NOTIFICATION_ID = 2
private const val TAG = "NetworkService"
var activityCount = 0
var instance: NetworkService? = null
}
}

View file

@ -12,12 +12,13 @@ data class PlayMessage(
@Serializable
data class SeekMessage(
val time: Long
val time: Double
)
@Serializable
data class PlaybackUpdateMessage(
val time: Long,
val time: Double,
val duration: Double,
val state: Int
)

View file

@ -8,7 +8,6 @@ import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.view.View
import android.view.Window
import android.view.WindowInsets
import android.view.WindowManager
import android.widget.ImageView
@ -19,7 +18,6 @@ import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import com.google.android.exoplayer2.source.dash.DashMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.text.ExoplayerCuesDecoder
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.upstream.DefaultDataSource
@ -41,6 +39,7 @@ class PlayerActivity : AppCompatActivity() {
private var _wasPlaying = false
val currentPosition get() = _exoPlayer.currentPosition
val duration get() = _exoPlayer.duration
val isPlaying get() = _exoPlayer.isPlaying
private val _connectivityEvents = object : ConnectivityManager.NetworkCallback() {
@ -111,7 +110,7 @@ class PlayerActivity : AppCompatActivity() {
super.onVolumeChanged(volume)
_scope.launch(Dispatchers.IO) {
try {
TcpListenerService.instance?.sendCastVolumeUpdate(VolumeUpdateMessage(volume.toDouble()))
NetworkService.instance?.sendCastVolumeUpdate(VolumeUpdateMessage(volume.toDouble()))
} catch (e: Throwable) {
Log.e(TAG, "Unhandled error sending volume update", e)
}
@ -167,7 +166,7 @@ class PlayerActivity : AppCompatActivity() {
play(PlayMessage(container, url, content, time))
instance = this
TcpListenerService.activityCount++
NetworkService.activityCount++
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
@ -242,7 +241,7 @@ class PlayerActivity : AppCompatActivity() {
_exoPlayer.removeListener(_playerEventListener)
_exoPlayer.stop()
_playerControlView.player = null
TcpListenerService.activityCount--
NetworkService.activityCount--
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@ -293,6 +292,7 @@ class PlayerActivity : AppCompatActivity() {
val mediaItem = mediaItemBuilder.build()
val mediaSource = when (playMessage.container) {
"application/dash+xml" -> DashMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
"application/x-mpegurl" -> HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
"application/vnd.apple.mpegurl" -> HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
else -> DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem)
}
@ -319,7 +319,7 @@ class PlayerActivity : AppCompatActivity() {
}
fun seek(seekMessage: SeekMessage) {
_exoPlayer.seekTo(seekMessage.time * 1000)
_exoPlayer.seekTo((seekMessage.time * 1000.0).toLong())
}
fun setVolume(setVolumeMessage: SetVolumeMessage) {

View file

@ -1,67 +1,25 @@
package com.futo.fcast.receiver
import android.app.*
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.net.NetworkInterface
import java.net.ServerSocket
import java.net.Socket
import java.util.*
import java.util.ArrayList
class TcpListenerService : Service() {
class TcpListenerService(private val _networkService: NetworkService, private val _onNewSession: (session: FCastSession) -> Unit) {
private var _serverSocket: ServerSocket? = null
private var _stopped: Boolean = false
private var _listenThread: Thread? = null
private var _clientThreads: ArrayList<Thread> = arrayListOf()
private var _sessions: ArrayList<FCastSession> = arrayListOf()
private var _discoveryService: DiscoveryService? = null
private var _scope: CoroutineScope? = null
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (instance != null) {
throw Exception("Do not start service when already running")
}
instance = this
Log.i(TAG, "Starting ListenerService")
_scope = CoroutineScope(Dispatchers.Main)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "TCP Listener Service"
val descriptionText = "Listening on port $PORT"
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
description = descriptionText
}
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
val notification: Notification = createNotificationBuilder()
.setContentTitle("TCP Listener Service")
.setContentText("Listening on port $PORT")
.setSmallIcon(R.mipmap.ic_launcher) // Ensure this icon exists
.build()
startForeground(NOTIFICATION_ID, notification)
_discoveryService = DiscoveryService(this)
_discoveryService?.start()
fun start() {
Log.i(TAG, "Starting TcpListenerService")
_listenThread = Thread {
Log.i(TAG, "Starting listener")
@ -75,58 +33,14 @@ class TcpListenerService : Service() {
_listenThread?.start()
_scope?.launch(Dispatchers.Main) {
while (!_stopped) {
try {
val player = PlayerActivity.instance
if (player != null) {
val updateMessage = PlaybackUpdateMessage(
player.currentPosition / 1000,
if (player.isPlaying) 1 else 2
)
withContext(Dispatchers.IO) {
try {
sendCastPlaybackUpdate(updateMessage)
} catch (eSend: Throwable) {
Log.e(TAG, "Unhandled error sending update", eSend)
}
Log.i(TAG, "Update sent")
}
}
} catch (eTimer: Throwable) {
Log.e(TAG, "Unhandled error on timer thread", eTimer)
} finally {
delay(1000)
}
}
}
Log.i(TAG, "Started ListenerService")
Toast.makeText(this, "Started FCast service", Toast.LENGTH_LONG).show()
return START_STICKY
Log.i(TAG, "Started TcpListenerService")
}
private fun createNotificationBuilder(): NotificationCompat.Builder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationCompat.Builder(this, CHANNEL_ID)
} else {
// For pre-Oreo, do not specify the channel ID
NotificationCompat.Builder(this)
}
}
fun stop() {
Log.i(TAG, "Stopping TcpListenerService")
override fun onDestroy() {
super.onDestroy()
Log.i(TAG, "Stopped ListenerService")
_stopped = true
_discoveryService?.stop()
_discoveryService = null
_serverSocket?.close()
_serverSocket = null
@ -137,134 +51,13 @@ class TcpListenerService : Service() {
_clientThreads.clear()
}
_scope?.cancel()
_scope = null
Toast.makeText(this, "Stopped FCast service", Toast.LENGTH_LONG).show()
instance = null
Log.i(TAG, "Stopped TcpListenerService")
}
private fun sendCastPlaybackUpdate(value: PlaybackUpdateMessage) {
fun forEachSession(handler: (FCastSession) -> Unit) {
synchronized(_sessions) {
for (session in _sessions) {
try {
session.sendPlaybackUpdate(value)
} catch (e: Throwable) {
Log.w(TAG, "Failed to send playback update", e)
}
}
}
}
fun sendCastVolumeUpdate(value: VolumeUpdateMessage) {
synchronized(_sessions) {
for (session in _sessions) {
try {
session.sendVolumeUpdate(value)
} catch (e: Throwable) {
Log.w(TAG, "Failed to send volume update", e)
}
}
}
}
fun onCastPlay(playMessage: PlayMessage) {
Log.i(TAG, "onPlay")
_scope?.launch {
try {
if (PlayerActivity.instance == null) {
val i = Intent(this@TcpListenerService, PlayerActivity::class.java)
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
i.putExtra("container", playMessage.container)
i.putExtra("url", playMessage.url)
i.putExtra("content", playMessage.content)
i.putExtra("time", playMessage.time)
if (activityCount > 0) {
startActivity(i)
} else if (Settings.canDrawOverlays(this@TcpListenerService)) {
val pi = PendingIntent.getActivity(this@TcpListenerService, 0, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
pi.send()
} else {
val pi = PendingIntent.getActivity(this@TcpListenerService, 0, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val playNotification = createNotificationBuilder()
.setContentTitle("FCast")
.setContentText("New content received. Tap to play.")
.setSmallIcon(R.drawable.ic_launcher_background)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.build()
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(PLAY_NOTIFICATION_ID, playNotification)
}
} else {
PlayerActivity.instance?.play(playMessage)
}
} catch (e: Throwable) {
Log.e(TAG, "Failed to play", e)
}
}
}
fun onCastPause() {
Log.i(TAG, "onPause")
_scope?.launch {
try {
PlayerActivity.instance?.pause()
} catch (e: Throwable) {
Log.e(TAG, "Failed to pause", e)
}
}
}
fun onCastResume() {
Log.i(TAG, "onResume")
_scope?.launch {
try {
PlayerActivity.instance?.resume()
} catch (e: Throwable) {
Log.e(TAG, "Failed to resume", e)
}
}
}
fun onCastStop() {
Log.i(TAG, "onStop")
_scope?.launch {
try {
PlayerActivity.instance?.finish()
} catch (e: Throwable) {
Log.e(TAG, "Failed to stop", e)
}
}
}
fun onCastSeek(seekMessage: SeekMessage) {
Log.i(TAG, "onSeek")
_scope?.launch {
try {
PlayerActivity.instance?.seek(seekMessage)
} catch (e: Throwable) {
Log.e(TAG, "Failed to seek", e)
}
}
}
fun onSetVolume(setVolumeMessage: SetVolumeMessage) {
Log.i(TAG, "onSetVolume")
_scope?.launch {
try {
PlayerActivity.instance?.setVolume(setVolumeMessage)
} catch (e: Throwable) {
Log.e(TAG, "Failed to seek", e)
handler(session)
}
}
}
@ -298,10 +91,11 @@ class TcpListenerService : Service() {
private fun handleClientConnection(socket: Socket) {
Log.i(TAG, "New connection received from ${socket.remoteSocketAddress}")
val session = FCastSession(socket, this)
val session = FCastSession(socket.getOutputStream(), socket.remoteSocketAddress, _networkService)
synchronized(_sessions) {
_sessions.add(session)
}
_onNewSession(session)
Log.i(TAG, "Waiting for data from ${socket.remoteSocketAddress}")
@ -333,12 +127,7 @@ class TcpListenerService : Service() {
}
companion object {
const val PORT = 46899
const val CHANNEL_ID = "TcpListenerServiceChannel"
const val NOTIFICATION_ID = 1
const val PLAY_NOTIFICATION_ID = 2
const val TAG = "TcpListenerService"
var activityCount = 0
var instance: TcpListenerService? = null
const val PORT = 46899
}
}
}

View file

@ -0,0 +1,69 @@
import android.util.Log
import com.futo.fcast.receiver.FCastSession
import com.futo.fcast.receiver.NetworkService
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.util.IdentityHashMap
class WebSocketListenerService(private val _networkService: NetworkService, private val _onNewSession: (session: FCastSession) -> Unit) : WebSocketServer(InetSocketAddress(PORT)) {
private var _sessions = IdentityHashMap<WebSocket, FCastSession>()
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
val session = FCastSession(WebSocketOutputStream(conn), conn.remoteSocketAddress, _networkService)
synchronized(_sessions) {
_sessions[conn] = session
}
_onNewSession(session)
Log.i(TAG, "New connection from ${conn.remoteSocketAddress}")
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
synchronized(_sessions) {
_sessions.remove(conn)
}
Log.i(TAG, "Closed connection from ${conn.remoteSocketAddress}")
}
override fun onMessage(conn: WebSocket?, message: String?) {
Log.i(TAG, "Received string message, but not processing: $message")
}
override fun onMessage(conn: WebSocket?, message: ByteBuffer?) {
if (message == null) {
Log.i(TAG, "Received byte message null")
return
}
Log.i(TAG, "Received byte message (offset = ${message.arrayOffset()}, size = ${message.remaining()})")
synchronized(_sessions) {
_sessions[conn]?.processBytes(message)
}
}
override fun onError(conn: WebSocket?, ex: Exception) {
Log.e(TAG, "Error in WebSocket connection", ex)
}
override fun onStart() {
Log.i(TAG, "WebSocketListenerService started on port $PORT")
}
fun forEachSession(handler: (FCastSession) -> Unit) {
synchronized(_sessions) {
for (pair in _sessions) {
handler(pair.value)
}
}
}
companion object {
const val TAG = "WebSocketListenerService"
const val PORT = 46898
}
}

View file

@ -0,0 +1,21 @@
import org.java_websocket.WebSocket
import java.io.IOException
import java.io.OutputStream
import java.nio.ByteBuffer
class WebSocketOutputStream(private val _webSocket: WebSocket) : OutputStream() {
@Throws(IOException::class)
override fun write(b: Int) {
write(byteArrayOf(b.toByte()), 0, 1)
}
@Throws(IOException::class)
override fun write(b: ByteArray, off: Int, len: Int) {
_webSocket.send(ByteBuffer.wrap(b, off, len))
}
@Throws(IOException::class)
override fun close() {
_webSocket.close()
}
}