Initial commit.
1
receivers/android/app/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
72
receivers/android/app/build.gradle
Normal file
|
@ -0,0 +1,72 @@
|
|||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.10'
|
||||
id 'org.ajoberstar.grgit' version '1.7.2'
|
||||
}
|
||||
|
||||
ext {
|
||||
gitVersionName = grgit.describe()
|
||||
gitVersionCode = gitVersionName != null && gitVersionName.isInteger() ? gitVersionName.toInteger() : 1
|
||||
}
|
||||
|
||||
println("Version Name: $gitVersionName")
|
||||
println("Version Code: $gitVersionCode")
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file('/opt/key.properties')
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.futo.fcast.receiver'
|
||||
compileSdk 33
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.futo.fcast.receiver"
|
||||
minSdk 24
|
||||
targetSdk 33
|
||||
versionCode gitVersionCode
|
||||
versionName gitVersionName
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.8.0'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0"
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
|
||||
implementation "com.squareup.okhttp3:okhttp:4.11.0"
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
}
|
21
receivers/android/app/proguard-rules.pro
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
|
@ -0,0 +1,24 @@
|
|||
package com.futo.fcast.receiver
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.futo.fcast.receiver", appContext.packageName)
|
||||
}
|
||||
}
|
57
receivers/android/app/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,57 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||
android:theme="@style/Theme.AppCompat.NoActionBar"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".PlayerActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||
android:theme="@style/Theme.AppCompat.NoActionBar" />
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.PACKAGE_ADDED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".TcpListenerService"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver android:name=".InstallReceiver" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,16 @@
|
|||
package com.futo.fcast.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED ||
|
||||
intent.action == Intent.ACTION_PACKAGE_ADDED ||
|
||||
intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) {
|
||||
val serviceIntent = Intent(context, TcpListenerService::class.java)
|
||||
context.startService(serviceIntent)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package com.futo.fcast.receiver
|
||||
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.util.Log
|
||||
|
||||
class DiscoveryService(private val _context: Context) {
|
||||
private var _nsdManager: NsdManager? = null
|
||||
private val _serviceType = "_fcast._tcp"
|
||||
|
||||
private fun getDeviceName(): String {
|
||||
return "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}"
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (_nsdManager != null) return
|
||||
|
||||
val serviceName = "FCast-${getDeviceName()}"
|
||||
Log.i("DiscoveryService", "Discovery service started. Name: $serviceName")
|
||||
|
||||
_nsdManager = _context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
val serviceInfo = NsdServiceInfo().apply {
|
||||
this.serviceName = serviceName
|
||||
this.serviceType = _serviceType
|
||||
this.port = 46899
|
||||
}
|
||||
|
||||
_nsdManager?.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (_nsdManager == null) return
|
||||
|
||||
_nsdManager?.unregisterService(registrationListener)
|
||||
_nsdManager = null
|
||||
}
|
||||
|
||||
private val registrationListener = object : NsdManager.RegistrationListener {
|
||||
override fun onServiceRegistered(serviceInfo: NsdServiceInfo) {
|
||||
Log.d("DiscoveryService", "Service registered: ${serviceInfo.serviceName}")
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.e("DiscoveryService", "Service registration failed: errorCode=$errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) {
|
||||
Log.d("DiscoveryService", "Service unregistered: ${serviceInfo.serviceName}")
|
||||
}
|
||||
|
||||
override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.e("DiscoveryService", "Service unregistration failed: errorCode=$errorCode")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,185 @@
|
|||
package com.futo.fcast.receiver
|
||||
|
||||
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.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
enum class SessionState {
|
||||
Idle,
|
||||
WaitingForLength,
|
||||
WaitingForData,
|
||||
Disconnected
|
||||
}
|
||||
|
||||
enum class Opcode(val value: Byte) {
|
||||
None(0),
|
||||
Play(1),
|
||||
Pause(2),
|
||||
Resume(3),
|
||||
Stop(4),
|
||||
Seek(5),
|
||||
PlaybackUpdate(6),
|
||||
VolumeUpdate(7),
|
||||
SetVolume(8)
|
||||
}
|
||||
|
||||
const val LENGTH_BYTES = 4
|
||||
const val MAXIMUM_PACKET_LENGTH = 32000
|
||||
|
||||
class FCastSession(private val _socket: Socket, private val _service: TcpListenerService) {
|
||||
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);
|
||||
|
||||
fun sendPlaybackUpdate(value: PlaybackUpdateMessage) {
|
||||
send(Opcode.PlaybackUpdate, value)
|
||||
}
|
||||
|
||||
fun sendVolumeUpdate(value: VolumeUpdateMessage) {
|
||||
send(Opcode.VolumeUpdate, value)
|
||||
}
|
||||
|
||||
private inline fun <reified T> send(opcode: Opcode, message: T) {
|
||||
try {
|
||||
val data: ByteArray;
|
||||
var jsonString: String? = null;
|
||||
if (message != null) {
|
||||
jsonString = Json.encodeToString(message);
|
||||
data = jsonString.encodeToByteArray();
|
||||
} else {
|
||||
data = ByteArray(0);
|
||||
}
|
||||
|
||||
val size = 1 + data.size;
|
||||
val outputStream = _outputStream;
|
||||
if (outputStream == null) {
|
||||
Log.w(TAG, "Failed to send $size bytes, output stream is null.");
|
||||
return;
|
||||
}
|
||||
|
||||
val serializedSizeLE = ByteArray(4);
|
||||
serializedSizeLE[0] = (size and 0xff).toByte();
|
||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte();
|
||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte();
|
||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte();
|
||||
outputStream.write(serializedSizeLE);
|
||||
|
||||
val opcodeBytes = ByteArray(1);
|
||||
opcodeBytes[0] = opcode.value;
|
||||
outputStream.write(opcodeBytes);
|
||||
|
||||
if (data.isNotEmpty()) {
|
||||
outputStream.write(data);
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sent $size bytes: '$jsonString'.");
|
||||
} catch (e: Throwable) {
|
||||
Log.i(TAG, "Failed to send message.", e);
|
||||
}
|
||||
}
|
||||
|
||||
fun processBytes(data: ByteArray, count: Int) {
|
||||
if (data.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "$count bytes received from ${_socket.remoteSocketAddress}")
|
||||
|
||||
when (_state) {
|
||||
SessionState.WaitingForLength -> handleLengthBytes(data, 0, count)
|
||||
SessionState.WaitingForData -> handlePacketBytes(data, 0, count)
|
||||
else -> throw Exception("Invalid state $_state encountered")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLengthBytes(data: ByteArray, offset: Int, count: Int) {
|
||||
val bytesToRead = minOf(LENGTH_BYTES - _bytesRead, count)
|
||||
val bytesRemaining = count - bytesToRead
|
||||
System.arraycopy(data, offset, _buffer, _bytesRead, bytesToRead)
|
||||
_bytesRead += bytesToRead
|
||||
|
||||
Log.i(TAG, "Read $bytesToRead bytes from packet")
|
||||
|
||||
if (_bytesRead >= LENGTH_BYTES) {
|
||||
_state = SessionState.WaitingForData
|
||||
|
||||
_packetLength = (_buffer[0].toInt() and 0xff) or
|
||||
((_buffer[1].toInt() and 0xff) shl 8) or
|
||||
((_buffer[2].toInt() and 0xff) shl 16) or
|
||||
((_buffer[3].toInt() and 0xff) shl 24)
|
||||
_bytesRead = 0
|
||||
|
||||
Log.i(TAG, "Packet length header received from ${_socket.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
|
||||
}
|
||||
|
||||
if (bytesRemaining > 0) {
|
||||
Log.i(TAG, "$bytesRemaining remaining bytes ${_socket.remoteSocketAddress} pushed to handlePacketBytes")
|
||||
handlePacketBytes(data, offset + bytesToRead, bytesRemaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePacketBytes(data: ByteArray, offset: Int, count: Int) {
|
||||
val bytesToRead = minOf(_packetLength - _bytesRead, count)
|
||||
val bytesRemaining = count - bytesToRead
|
||||
System.arraycopy(data, offset, _buffer, _bytesRead, bytesToRead)
|
||||
_bytesRead += bytesToRead
|
||||
|
||||
Log.i(TAG, "Read $bytesToRead bytes from packet")
|
||||
|
||||
if (_bytesRead >= _packetLength) {
|
||||
Log.i(TAG, "Packet finished receiving from ${_socket.remoteSocketAddress} of $_packetLength bytes.")
|
||||
handlePacket()
|
||||
|
||||
_state = SessionState.WaitingForLength
|
||||
_packetLength = 0
|
||||
_bytesRead = 0
|
||||
|
||||
if (bytesRemaining > 0) {
|
||||
Log.i(TAG, "$bytesRemaining remaining bytes ${_socket.remoteSocketAddress} pushed to handleLengthBytes")
|
||||
handleLengthBytes(data, offset + bytesToRead, bytesRemaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePacket() {
|
||||
Log.i(TAG, "Processing packet of $_bytesRead bytes from ${_socket.remoteSocketAddress}")
|
||||
|
||||
val opcode = Opcode.values().firstOrNull { it.value == _buffer[0] } ?: Opcode.None
|
||||
val body = if (_packetLength > 1) _buffer.copyOfRange(1, _packetLength)
|
||||
.toString(Charsets.UTF_8) else null
|
||||
|
||||
Log.i(TAG, "Received packet (opcode: ${opcode}, body: '${body}')")
|
||||
|
||||
try {
|
||||
when (opcode) {
|
||||
Opcode.Play -> _service.onCastPlay(Json.decodeFromString(body!!))
|
||||
Opcode.Pause -> _service.onCastPause()
|
||||
Opcode.Resume -> _service.onCastResume()
|
||||
Opcode.Stop -> _service.onCastStop()
|
||||
Opcode.Seek -> _service.onCastSeek(Json.decodeFromString(body!!))
|
||||
Opcode.SetVolume -> _service.onSetVolume(Json.decodeFromString(body!!))
|
||||
else -> { }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to handle packet (opcode: ${opcode}, body: '${body}')")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "FCastSession"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package com.futo.fcast.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.util.Log
|
||||
|
||||
class InstallReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
|
||||
Log.i(TAG, "Received status $status.")
|
||||
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val activityIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
|
||||
if (activityIntent == null) {
|
||||
Log.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.")
|
||||
return
|
||||
}
|
||||
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> onReceiveResult?.invoke(null)
|
||||
PackageInstaller.STATUS_FAILURE -> onReceiveResult?.invoke(context.getString(R.string.general_failure))
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> onReceiveResult?.invoke(context.getString(R.string.aborted))
|
||||
PackageInstaller.STATUS_FAILURE_BLOCKED -> onReceiveResult?.invoke(context.getString(R.string.blocked))
|
||||
PackageInstaller.STATUS_FAILURE_CONFLICT -> onReceiveResult?.invoke(context.getString(R.string.conflict))
|
||||
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> onReceiveResult?.invoke(context.getString(R.string.incompatible))
|
||||
PackageInstaller.STATUS_FAILURE_INVALID -> onReceiveResult?.invoke(context.getString(R.string.invalid))
|
||||
PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult?.invoke(context.getString(R.string.not_enough_storage))
|
||||
else -> {
|
||||
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
onReceiveResult?.invoke(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "InstallReceiver"
|
||||
var onReceiveResult: ((String?) -> Unit)? = null
|
||||
}
|
||||
}
|
|
@ -0,0 +1,385 @@
|
|||
package com.futo.fcast.receiver
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AlertDialog
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.*
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.NetworkInterface
|
||||
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonUpdate: LinearLayout
|
||||
private lateinit var _text: TextView
|
||||
private lateinit var _textIPs: TextView
|
||||
private lateinit var _textProgress: TextView
|
||||
private lateinit var _updateSpinner: ImageView
|
||||
private var _updating: Boolean = false
|
||||
|
||||
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Main)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
_buttonUpdate = findViewById(R.id.button_update)
|
||||
_text = findViewById(R.id.text_dialog)
|
||||
_textIPs = findViewById(R.id.text_ips)
|
||||
_textProgress = findViewById(R.id.text_progress)
|
||||
_updateSpinner = findViewById(R.id.update_spinner)
|
||||
|
||||
_text.text = getString(R.string.checking_for_updates)
|
||||
_buttonUpdate.visibility = View.INVISIBLE
|
||||
(_updateSpinner.drawable as Animatable?)?.start()
|
||||
|
||||
_buttonUpdate.setOnClickListener {
|
||||
if (_updating) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
_updating = true
|
||||
update()
|
||||
}
|
||||
|
||||
_scope.launch(Dispatchers.IO) {
|
||||
checkForUpdates()
|
||||
}
|
||||
|
||||
_textIPs.text = "IPs\n" + getIPs().joinToString("\n") + "\n\nPort\n46899"
|
||||
TcpListenerService.activityCount++
|
||||
|
||||
if (checkAndRequestPermissions()) {
|
||||
Log.i(TAG, "Notification permission already granted")
|
||||
restartService()
|
||||
} else {
|
||||
restartService()
|
||||
}
|
||||
|
||||
requestSystemAlertWindowPermission()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
InstallReceiver.onReceiveResult = null
|
||||
_scope.cancel()
|
||||
TcpListenerService.activityCount--
|
||||
}
|
||||
|
||||
private fun restartService() {
|
||||
val i = TcpListenerService.instance
|
||||
if (i != null) {
|
||||
i.stopSelf()
|
||||
}
|
||||
|
||||
startService(Intent(this, TcpListenerService::class.java))
|
||||
}
|
||||
|
||||
private fun checkAndRequestPermissions(): Boolean {
|
||||
val listPermissionsNeeded = arrayListOf<String>()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val notificationPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (notificationPermission != PackageManager.PERMISSION_GRANTED) {
|
||||
listPermissionsNeeded.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
if (listPermissionsNeeded.isNotEmpty()) {
|
||||
ActivityCompat.requestPermissions(this, listPermissionsNeeded.toTypedArray(), REQUEST_ID_MULTIPLE_PERMISSIONS)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun requestSystemAlertWindowPermission() {
|
||||
if (!Settings.canDrawOverlays(this)) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.permission_dialog_title)
|
||||
.setMessage(R.string.permission_dialog_message)
|
||||
.setPositiveButton(R.string.permission_dialog_positive_button) { _, _ ->
|
||||
val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName"))
|
||||
startActivityForResult(intent, REQUEST_CODE)
|
||||
}
|
||||
.setNegativeButton(R.string.permission_dialog_negative_button) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
Toast.makeText(this, "Permission is required to work in background", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
if (requestCode == REQUEST_CODE) {
|
||||
if (Settings.canDrawOverlays(this)) {
|
||||
// Permission granted, you can launch the activity from the foreground service
|
||||
Toast.makeText(this, "Alert window permission granted", Toast.LENGTH_LONG).show()
|
||||
Log.i(TAG, "Alert window permission granted")
|
||||
} else {
|
||||
// Permission denied, notify the user and request again if necessary
|
||||
Toast.makeText(this, "Permission is required to work in background", Toast.LENGTH_LONG).show()
|
||||
Log.i(TAG, "Alert window permission denied")
|
||||
}
|
||||
}
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
when (requestCode) {
|
||||
REQUEST_ID_MULTIPLE_PERMISSIONS -> {
|
||||
val perms: MutableMap<String, Int> = HashMap()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
perms[Manifest.permission.POST_NOTIFICATIONS] = PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
if (grantResults.isNotEmpty()) {
|
||||
var i = 0
|
||||
while (i < permissions.size) {
|
||||
perms[permissions[i]] = grantResults[i]
|
||||
i++
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (perms[Manifest.permission.POST_NOTIFICATIONS] == PackageManager.PERMISSION_GRANTED) {
|
||||
Log.i(TAG, "Notification permission granted")
|
||||
Toast.makeText(this, "Notification permission granted", Toast.LENGTH_LONG).show()
|
||||
restartService()
|
||||
} else {
|
||||
Log.i(TAG, "Notification permission not granted")
|
||||
Toast.makeText(this, "App may not fully work without notification permission", Toast.LENGTH_LONG).show()
|
||||
restartService()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkForUpdates() {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val latestVersion = downloadVersionCode()
|
||||
|
||||
if (latestVersion != null) {
|
||||
val currentVersion = BuildConfig.VERSION_CODE;
|
||||
Log.i(TAG, "Current version $currentVersion latest version $latestVersion.");
|
||||
|
||||
if (latestVersion > currentVersion) {
|
||||
withContext(Dispatchers.Main) {
|
||||
try {
|
||||
(_updateSpinner.drawable as Animatable?)?.stop()
|
||||
_updateSpinner.visibility = View.INVISIBLE
|
||||
_text.text = resources.getText(R.string.there_is_an_update_available_do_you_wish_to_update)
|
||||
_buttonUpdate.visibility = View.VISIBLE
|
||||
} catch (e: Throwable) {
|
||||
Toast.makeText(this@MainActivity, "Failed to show update dialog", Toast.LENGTH_LONG).show();
|
||||
Log.w(TAG, "Error occurred in update dialog.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
_updateSpinner.visibility = View.INVISIBLE
|
||||
_text.text = getString(R.string.no_updates_available)
|
||||
Toast.makeText(this@MainActivity, "Already on latest version", Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Failed to retrieve version from version URL.");
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(this@MainActivity, "Failed to retrieve version", Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to check for updates.", e);
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(this@MainActivity, "Failed to check for updates", Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadVersionCode(): Int? {
|
||||
val client = OkHttpClient()
|
||||
val request = okhttp3.Request.Builder()
|
||||
.method("GET", null)
|
||||
.url(VERSION_URL)
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return response.body?.string()?.trim()?.toInt()
|
||||
}
|
||||
|
||||
private fun update() {
|
||||
_updateSpinner.visibility = View.VISIBLE
|
||||
_buttonUpdate.visibility = Button.INVISIBLE
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
_text.text = resources.getText(R.string.downloading_update)
|
||||
(_updateSpinner.drawable as Animatable?)?.start()
|
||||
|
||||
_scope.launch(Dispatchers.IO) {
|
||||
var inputStream: InputStream? = null
|
||||
try {
|
||||
val client = OkHttpClient()
|
||||
val request = okhttp3.Request.Builder()
|
||||
.method("GET", null)
|
||||
.url(APK_URL)
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val body = response.body
|
||||
if (response.isSuccessful && body != null) {
|
||||
inputStream = body.byteStream()
|
||||
val dataLength = body.contentLength()
|
||||
install(inputStream, dataLength)
|
||||
} else {
|
||||
throw Exception("Failed to download latest version of app.")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Exception thrown while downloading and installing latest version of app.", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
onReceiveResult("Failed to download update.")
|
||||
}
|
||||
} finally {
|
||||
inputStream?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun install(inputStream: InputStream, dataLength: Long) {
|
||||
var lastProgressText = ""
|
||||
var session: PackageInstaller.Session? = null
|
||||
|
||||
try {
|
||||
Log.i(TAG, "Hooked InstallReceiver.onReceiveResult.")
|
||||
InstallReceiver.onReceiveResult = { message -> onReceiveResult(message) }
|
||||
|
||||
val packageInstaller: PackageInstaller = packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
val sessionId = packageInstaller.createSession(params)
|
||||
session = packageInstaller.openSession(sessionId)
|
||||
|
||||
session.openWrite("package", 0, dataLength).use { sessionStream ->
|
||||
inputStream.copyToOutputStream(dataLength, sessionStream) { progress ->
|
||||
val progressText = "${(progress * 100.0f).toInt()}%"
|
||||
if (lastProgressText != progressText) {
|
||||
lastProgressText = progressText
|
||||
|
||||
//TODO: Use proper scope
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
_textProgress.text = progressText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.fsync(sessionStream)
|
||||
}
|
||||
|
||||
val intent = Intent(this, InstallReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
val statusReceiver = pendingIntent.intentSender
|
||||
|
||||
session.commit(statusReceiver)
|
||||
session.close()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_textProgress.text = ""
|
||||
_text.text = resources.getText(R.string.installing_update)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Exception thrown while downloading and installing latest version of app.", e)
|
||||
session?.abandon()
|
||||
withContext(Dispatchers.Main) {
|
||||
onReceiveResult("Failed to download update.")
|
||||
}
|
||||
}
|
||||
finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onReceiveResult(result: String?) {
|
||||
InstallReceiver.onReceiveResult = null
|
||||
Log.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
||||
|
||||
(_updateSpinner.drawable as Animatable?)?.stop()
|
||||
|
||||
if (result == null || result.isBlank()) {
|
||||
_updateSpinner.setImageResource(R.drawable.ic_update_success)
|
||||
_text.text = resources.getText(R.string.success)
|
||||
} else {
|
||||
_updateSpinner.setImageResource(R.drawable.ic_update_fail)
|
||||
_text.text = "${resources.getText(R.string.failed_to_update_with_error)}: '$result'."
|
||||
}
|
||||
}
|
||||
|
||||
private fun InputStream.copyToOutputStream(inputStreamLength: Long, outputStream: OutputStream, onProgress: (Float) -> Unit) {
|
||||
val buffer = ByteArray(16384)
|
||||
var n: Int
|
||||
var total = 0
|
||||
val inputStreamLengthFloat = inputStreamLength.toFloat()
|
||||
|
||||
while (read(buffer).also { n = it } >= 0) {
|
||||
total += n
|
||||
outputStream.write(buffer, 0, n)
|
||||
onProgress.invoke(total.toFloat() / inputStreamLengthFloat)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getIPs(): List<String> {
|
||||
val ips = arrayListOf<String>()
|
||||
for (intf in NetworkInterface.getNetworkInterfaces()) {
|
||||
for (addr in intf.inetAddresses) {
|
||||
if (addr.isLoopbackAddress) {
|
||||
continue
|
||||
}
|
||||
|
||||
Log.i(TcpListenerService.TAG, "Running on ${addr.hostAddress}:${TcpListenerService.PORT}")
|
||||
addr.hostAddress?.let { ips.add(it) }
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "MainActivity"
|
||||
const val VERSION_URL = "https://releases.grayjay.app/fcast-version.txt"
|
||||
const val APK_URL = "https://releases.grayjay.app/fcast-release.apk"
|
||||
const val REQUEST_ID_MULTIPLE_PERMISSIONS = 1
|
||||
const val REQUEST_CODE = 2
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package com.futo.fcast.receiver
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlayMessage(
|
||||
val container: String,
|
||||
val url: String? = null,
|
||||
val content: String? = null,
|
||||
val time: Long? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeekMessage(
|
||||
val time: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaybackUpdateMessage(
|
||||
val time: Long,
|
||||
val state: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VolumeUpdateMessage(
|
||||
val volume: Double
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SetVolumeMessage(
|
||||
val volume: Double
|
||||
)
|
|
@ -0,0 +1,214 @@
|
|||
package com.futo.fcast.receiver
|
||||
|
||||
import android.content.Context
|
||||
import android.net.*
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
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.trackselection.DefaultTrackSelector
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSource
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.abs
|
||||
|
||||
class PlayerActivity : AppCompatActivity() {
|
||||
private lateinit var _playerControlView: StyledPlayerView
|
||||
private lateinit var _exoPlayer: ExoPlayer
|
||||
private var _shouldPlaybackRestartOnConnectivity: Boolean = false
|
||||
private lateinit var _connectivityManager: ConnectivityManager
|
||||
private lateinit var _scope: CoroutineScope
|
||||
|
||||
val currentPosition get() = _exoPlayer.currentPosition
|
||||
val isPlaying get() = _exoPlayer.isPlaying
|
||||
|
||||
private val _connectivityEvents = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
super.onAvailable(network)
|
||||
Log.i(TAG, "_connectivityEvents onAvailable")
|
||||
|
||||
try {
|
||||
_scope.launch(Dispatchers.Main) {
|
||||
Log.i(TAG, "onConnectionAvailable")
|
||||
|
||||
val pos = _exoPlayer.currentPosition
|
||||
val dur = _exoPlayer.duration
|
||||
if (_shouldPlaybackRestartOnConnectivity && abs(pos - dur) > 2000) {
|
||||
Log.i(TAG, "Playback ended due to connection loss, resuming playback since connection is restored.")
|
||||
_exoPlayer.playWhenReady = true
|
||||
_exoPlayer.prepare()
|
||||
_exoPlayer.play()
|
||||
}
|
||||
}
|
||||
} catch(ex: Throwable) {
|
||||
Log.w(TAG, "Failed to handle connection available event", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _playerEventListener = object: Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
super.onPlaybackStateChanged(playbackState)
|
||||
|
||||
if (_shouldPlaybackRestartOnConnectivity && playbackState == ExoPlayer.STATE_READY) {
|
||||
Log.i(TAG, "_shouldPlaybackRestartOnConnectivity=false")
|
||||
_shouldPlaybackRestartOnConnectivity = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
super.onPlayerError(error)
|
||||
|
||||
when (error.errorCode) {
|
||||
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
|
||||
PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
|
||||
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
|
||||
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
|
||||
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
|
||||
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> {
|
||||
Log.i(TAG, "IO error, set _shouldPlaybackRestartOnConnectivity=true")
|
||||
_shouldPlaybackRestartOnConnectivity = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onVolumeChanged(volume: Float) {
|
||||
super.onVolumeChanged(volume)
|
||||
_scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
TcpListenerService.instance?.sendCastVolumeUpdate(VolumeUpdateMessage(volume.toDouble()))
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Unhandled error sending volume update", e)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Update sent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.i(TAG, "onCreate")
|
||||
|
||||
setContentView(R.layout.activity_player)
|
||||
|
||||
_playerControlView = findViewById(R.id.player_control_view)
|
||||
_scope = CoroutineScope(Dispatchers.Main)
|
||||
|
||||
val trackSelector = DefaultTrackSelector(this)
|
||||
trackSelector.parameters = trackSelector.parameters
|
||||
.buildUpon()
|
||||
.setPreferredTextLanguage("en")
|
||||
.setSelectUndeterminedTextLanguage(true)
|
||||
.build()
|
||||
|
||||
_exoPlayer = ExoPlayer.Builder(this)
|
||||
.setTrackSelector(trackSelector).build()
|
||||
_exoPlayer.addListener(_playerEventListener)
|
||||
_playerControlView.player = _exoPlayer
|
||||
_playerControlView.controllerAutoShow = false
|
||||
|
||||
Log.i(TAG, "Attached onConnectionAvailable listener.")
|
||||
_connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val netReq = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
.build()
|
||||
_connectivityManager.registerNetworkCallback(netReq, _connectivityEvents)
|
||||
|
||||
val container = intent.getStringExtra("container") ?: ""
|
||||
val url = intent.getStringExtra("url")
|
||||
val content = intent.getStringExtra("content")
|
||||
val time = intent.getLongExtra("time", 0L)
|
||||
|
||||
play(PlayMessage(container, url, content, time))
|
||||
|
||||
instance = this
|
||||
TcpListenerService.activityCount++
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.i(TAG, "onDestroy")
|
||||
|
||||
instance = null
|
||||
_scope.cancel()
|
||||
_connectivityManager.unregisterNetworkCallback(_connectivityEvents)
|
||||
_exoPlayer.removeListener(_playerEventListener)
|
||||
_exoPlayer.stop()
|
||||
_playerControlView.player = null
|
||||
TcpListenerService.activityCount--
|
||||
}
|
||||
|
||||
fun play(playMessage: PlayMessage) {
|
||||
val mediaItemBuilder = MediaItem.Builder()
|
||||
if (playMessage.container.isNotEmpty()) {
|
||||
mediaItemBuilder.setMimeType(playMessage.container)
|
||||
}
|
||||
|
||||
if (!playMessage.url.isNullOrEmpty()) {
|
||||
mediaItemBuilder.setUri(Uri.parse(playMessage.url))
|
||||
} else if (!playMessage.content.isNullOrEmpty()) {
|
||||
val tempFile = File.createTempFile("content_", ".tmp", cacheDir)
|
||||
tempFile.deleteOnExit()
|
||||
FileOutputStream(tempFile).use { output ->
|
||||
output.bufferedWriter().use { writer ->
|
||||
writer.write(playMessage.content)
|
||||
}
|
||||
}
|
||||
|
||||
mediaItemBuilder.setUri(Uri.fromFile(tempFile))
|
||||
} else {
|
||||
throw IllegalArgumentException("Either URL or content must be provided.")
|
||||
}
|
||||
|
||||
val dataSourceFactory = DefaultDataSource.Factory(this)
|
||||
val mediaItem = mediaItemBuilder.build()
|
||||
val mediaSource = when (playMessage.container) {
|
||||
"application/dash+xml" -> DashMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
|
||||
"application/vnd.apple.mpegurl" -> HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
|
||||
else -> DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem)
|
||||
}
|
||||
|
||||
_exoPlayer.setMediaSource(mediaSource)
|
||||
|
||||
if (playMessage.time != null) {
|
||||
_exoPlayer.seekTo(playMessage.time * 1000)
|
||||
}
|
||||
|
||||
_exoPlayer.playWhenReady = true
|
||||
_exoPlayer.prepare()
|
||||
_exoPlayer.play()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
_exoPlayer.pause()
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
_exoPlayer.play()
|
||||
}
|
||||
|
||||
fun seek(seekMessage: SeekMessage) {
|
||||
_exoPlayer.seekTo(seekMessage.time * 1000)
|
||||
}
|
||||
|
||||
fun setVolume(setVolumeMessage: SetVolumeMessage) {
|
||||
_exoPlayer.volume = setVolumeMessage.volume.toFloat()
|
||||
}
|
||||
|
||||
companion object {
|
||||
var instance: PlayerActivity? = null
|
||||
private const val TAG = "PlayerActivity"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,338 @@
|
|||
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 java.io.BufferedInputStream
|
||||
import java.net.NetworkInterface
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.*
|
||||
|
||||
class TcpListenerService : Service() {
|
||||
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)
|
||||
|
||||
createNotificationChannel()
|
||||
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("TCP Listener Service")
|
||||
.setContentText("Listening on port $PORT")
|
||||
.setSmallIcon(R.drawable.ic_launcher_background)
|
||||
.build()
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
|
||||
_discoveryService = DiscoveryService(this)
|
||||
_discoveryService?.start()
|
||||
|
||||
_listenThread = Thread {
|
||||
Log.i(TAG, "Starting listener")
|
||||
|
||||
try {
|
||||
listenForIncomingConnections()
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Stopped listening for connections due to an unexpected error", e)
|
||||
}
|
||||
}
|
||||
|
||||
_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
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
Log.i(TAG, "Stopped ListenerService")
|
||||
_stopped = true
|
||||
|
||||
_discoveryService?.stop()
|
||||
_discoveryService = null
|
||||
|
||||
_serverSocket?.close()
|
||||
_serverSocket = null;
|
||||
|
||||
_listenThread?.join()
|
||||
_listenThread = null
|
||||
|
||||
synchronized(_clientThreads) {
|
||||
_clientThreads.clear()
|
||||
}
|
||||
|
||||
_scope?.cancel()
|
||||
_scope = null
|
||||
|
||||
Toast.makeText(this, "Stopped FCast service", Toast.LENGTH_LONG).show()
|
||||
instance = null
|
||||
}
|
||||
|
||||
private fun sendCastPlaybackUpdate(value: PlaybackUpdateMessage) {
|
||||
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 = NotificationCompat.Builder(this@TcpListenerService, CHANNEL_ID)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun listenForIncomingConnections() {
|
||||
Log.i(TAG, "Started listening for incoming connections")
|
||||
|
||||
_serverSocket = ServerSocket(PORT)
|
||||
|
||||
while (!_stopped) {
|
||||
val clientSocket = _serverSocket?.accept() ?: break
|
||||
|
||||
val clientThread = Thread {
|
||||
try {
|
||||
handleClientConnection(clientSocket)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed handle client connection due to an error", e)
|
||||
}
|
||||
}
|
||||
|
||||
synchronized(_clientThreads) {
|
||||
_clientThreads.add(clientThread)
|
||||
}
|
||||
|
||||
clientThread.start()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Stopped listening for incoming connections")
|
||||
}
|
||||
|
||||
private fun handleClientConnection(socket: Socket) {
|
||||
Log.i(TAG, "New connection received from ${socket.remoteSocketAddress}")
|
||||
|
||||
val session = FCastSession(socket, this)
|
||||
synchronized(_sessions) {
|
||||
_sessions.add(session)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Waiting for data from ${socket.remoteSocketAddress}")
|
||||
|
||||
val bufferSize = 4096
|
||||
val buffer = ByteArray(bufferSize)
|
||||
val inputStream = BufferedInputStream(socket.getInputStream())
|
||||
|
||||
var bytesRead: Int
|
||||
while (!_stopped) {
|
||||
bytesRead = inputStream.read(buffer, 0, bufferSize)
|
||||
if (bytesRead == -1) {
|
||||
break
|
||||
}
|
||||
|
||||
session.processBytes(buffer, bytesRead)
|
||||
}
|
||||
|
||||
socket.close()
|
||||
|
||||
synchronized(_sessions) {
|
||||
_sessions.remove(session)
|
||||
}
|
||||
|
||||
synchronized(_clientThreads) {
|
||||
_clientThreads.remove(Thread.currentThread())
|
||||
}
|
||||
|
||||
Log.i(TAG, "Disconnected ${socket.remoteSocketAddress}")
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package com.futo.fcast.receiver
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class Updater {
|
||||
companion object {
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:duration="1500"
|
||||
android:propertyName="rotation"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="360"
|
||||
android:repeatMode="restart"
|
||||
android:repeatCount="infinite"
|
||||
android:interpolator="@android:anim/linear_interpolator" />
|
|
@ -0,0 +1,30 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#2D63ED" />
|
||||
<corners android:radius="6dp" />
|
||||
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||
</shape>
|
|
@ -0,0 +1,170 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
18
receivers/android/app/src/main/res/drawable/ic_update.xml
Normal file
|
@ -0,0 +1,18 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="465dp"
|
||||
android:height="511dp"
|
||||
android:viewportWidth="465"
|
||||
android:viewportHeight="511">
|
||||
<group
|
||||
android:name="rotationGroup1"
|
||||
android:pivotX="232.5"
|
||||
android:pivotY="255.5"
|
||||
android:scaleX="0.9"
|
||||
android:scaleY="0.9"
|
||||
android:rotation="0.0">
|
||||
<path
|
||||
android:pathData="M278,0.5L269.79,25.94C174.26,9.52 82.05,59.38 34.52,137.05C-14.34,216.89 -16.12,325.96 70.25,423.92L92.75,404.08C15.12,316.04 18.09,221.37 60.11,152.71C101.3,85.39 179.93,42.91 260.46,54.83L252.5,79.5L334.5,63.5L278,0.5ZM187.5,510.42L195.71,484.98C291.24,501.4 383.45,451.55 430.98,373.88C479.84,294.04 481.62,184.96 395.25,87L372.75,106.84C450.38,194.88 447.41,289.56 405.39,358.22C364.2,425.54 285.57,468.01 205.04,456.1L213,431.42L131,447.42L187.5,510.42Z"
|
||||
android:fillColor="#c9c9c9"
|
||||
android:fillType="evenOdd"/>
|
||||
</group>
|
||||
</vector>
|
|
@ -0,0 +1,6 @@
|
|||
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:drawable="@drawable/ic_update" >
|
||||
<target
|
||||
android:name="rotationGroup1"
|
||||
android:animation="@animator/rotation_1500_clockwise" />
|
||||
</animated-vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="251dp"
|
||||
android:height="251dp"
|
||||
android:viewportWidth="251"
|
||||
android:viewportHeight="251">
|
||||
<path
|
||||
android:pathData="M251,25.28L225.72,0L125.5,100.22L25.28,0L0,25.28L100.22,125.5L0,225.72L25.28,251L125.5,150.78L225.72,251L251,225.72L150.78,125.5L251,25.28Z"
|
||||
android:fillColor="#363636"/>
|
||||
</vector>
|
|
@ -0,0 +1,4 @@
|
|||
<vector android:height="251dp" android:viewportHeight="258"
|
||||
android:viewportWidth="338" android:width="328.82947dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#363636" android:pathData="M107.41,203.55L27.29,123.32L0,150.45L107.41,258L338,27.13L310.91,0L107.41,203.55Z"/>
|
||||
</vector>
|
BIN
receivers/android/app/src/main/res/font/inter_bold.ttf
Normal file
BIN
receivers/android/app/src/main/res/font/inter_regular.ttf
Normal file
112
receivers/android/app/src/main/res/layout/activity_main.xml
Normal file
|
@ -0,0 +1,112 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:background="@color/black"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="FCast"
|
||||
android:textColor="@color/white"
|
||||
android:gravity="center"
|
||||
android:fontFamily="@font/inter_bold"
|
||||
android:textSize="40dp"
|
||||
android:includeFontPadding="false"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintLeft_toRightOf="@id/text_title"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_marginStart="10dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/update_spinner"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
app:srcCompat="@drawable/ic_update_animated" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:layout_gravity="center"
|
||||
android:textSize="10sp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular" />
|
||||
|
||||
</FrameLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_ips"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/white"
|
||||
android:gravity="center"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:textSize="12dp"
|
||||
tools:text="123" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_dialog"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/there_is_an_update_available_do_you_wish_to_update"
|
||||
android:textSize="14sp"
|
||||
android:minLines="3"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:layout_marginTop="30dp"
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_marginEnd="30dp"
|
||||
android:gravity="center"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginBottom="28dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_update"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_button_primary"
|
||||
android:layout_marginEnd="28dp"
|
||||
android:clickable="true">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/update"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingEnd="28dp"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/black">
|
||||
|
||||
<com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
android:id="@+id/player_control_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:keepScreenOn="true"
|
||||
app:use_controller="true" />
|
||||
</FrameLayout>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
BIN
receivers/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 2.8 KiB |
BIN
receivers/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 982 B |
After Width: | Height: | Size: 1.7 KiB |
BIN
receivers/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 5.8 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 7.6 KiB |
10
receivers/android/app/src/main/res/values/colors.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
24
receivers/android/app/src/main/res/values/strings.xml
Normal file
|
@ -0,0 +1,24 @@
|
|||
<resources>
|
||||
<string name="app_name">FCastReceiver</string>
|
||||
<string name="general_failure">The operation failed in a generic way</string>
|
||||
<string name="aborted">The operation failed because it was actively aborted</string>
|
||||
<string name="blocked">The operation failed because it was blocked</string>
|
||||
<string name="conflict">The operation failed because it conflicts (or is inconsistent with) with another package already installed on the device</string>
|
||||
<string name="incompatible">The operation failed because it is fundamentally incompatible with this device</string>
|
||||
<string name="invalid">The operation failed because one or more of the APKs was invalid</string>
|
||||
<string name="not_enough_storage">The operation failed because of storage issues</string>
|
||||
<string name="downloading_update">Downloading update...</string>
|
||||
<string name="installing_update">Installing update...</string>
|
||||
<string name="success">Success</string>
|
||||
<string name="failed_to_update_with_error">Failed to update package with error</string>
|
||||
<string name="there_is_an_update_available_do_you_wish_to_update">There is an update available for FCast receiver, do you wish to update?</string>
|
||||
<string name="never">Never</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="update">Update</string>
|
||||
<string name="checking_for_updates">Checking for updates...</string>
|
||||
<string name="no_updates_available">No updates available</string>
|
||||
<string name="permission_dialog_title">System Alert Window Permission</string>
|
||||
<string name="permission_dialog_message">This app requires the System Alert Window permission to display content on top of other apps. Please grant the permission.</string>
|
||||
<string name="permission_dialog_positive_button">Allow</string>
|
||||
<string name="permission_dialog_negative_button">Cancel</string>
|
||||
</resources>
|
7
receivers/android/app/src/main/res/values/themes.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme.Player" parent="Theme.AppCompat.NoActionBar">
|
||||
</style>
|
||||
|
||||
<style name="Theme.Main" parent="Theme.AppCompat.NoActionBar">
|
||||
</style>
|
||||
</resources>
|
13
receivers/android/app/src/main/res/xml/backup_rules.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older that API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
|
@ -0,0 +1,17 @@
|
|||
package com.futo.fcast.receiver
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|