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

Initial commit.

This commit is contained in:
Koen 2023-06-20 08:45:01 +02:00
commit c8394f6a8e
99 changed files with 8173 additions and 0 deletions

1
receivers/android/app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View 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'
}

View 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

View file

@ -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)
}
}

View 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>

View file

@ -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)
}
}
}

View file

@ -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")
}
}
}

View file

@ -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"
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
)

View file

@ -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"
}
}

View file

@ -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
}
}

View file

@ -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 {
}
}

View file

@ -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" />

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

Binary file not shown.

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View 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>

View 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>

View 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>

View 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>

View file

@ -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>

View file

@ -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)
}
}