This commit is contained in:
tom5079
2024-04-15 18:19:49 -07:00
parent 03b88c5b4b
commit b0e194898e
17 changed files with 632 additions and 34 deletions

View File

@@ -236,6 +236,13 @@ class Pupil : Application() {
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_SECRET
})
manager.createNotificationChannel(NotificationChannel("transfer", getString(R.string.channel_transfer), NotificationManager.IMPORTANCE_LOW).apply {
description = getString(R.string.channel_transfer_description)
enableLights(false)
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_SECRET
})
}
AppCompatDelegate.setDefaultNightMode(when (Preferences.get<Boolean>("dark_mode")) {

View File

@@ -0,0 +1,97 @@
package xyz.quaver.pupil.services
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import io.ktor.utils.io.writeStringUtf8
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
class TransferClientService : Service() {
private val selectorManager = SelectorManager(Dispatchers.IO)
private val channel = Channel<String>()
private var job: Job? = null
private fun startForeground() = runCatching {
val notification = NotificationCompat.Builder(this, "transfer")
.setContentTitle("Pupil")
.setContentText("Transfer server is running")
.setSmallIcon(R.drawable.ic_notification)
.build()
ServiceCompat.startForeground(
this,
1,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else 0
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val address = intent?.getStringExtra("address") ?: run {
stopSelf(startId)
return START_STICKY
}
startForeground()
Log.d("PUPILD", "Starting service with address $address")
job?.cancel()
job = CoroutineScope(Dispatchers.IO).launch {
Log.d("PUPILD", "Connecting to $address")
val socket = aSocket(selectorManager).tcp().connect(address, 12221)
Log.d("PUPILD", "Connected to $address")
val readChannel = socket.openReadChannel()
val writeChannel = socket.openWriteChannel(autoFlush = true)
runCatching {
while (true) {
val message = channel.receive()
Log.d("PUPILD", "Sending message $message!")
writeChannel.writeStringUtf8(message)
Log.d("PUPILD", readChannel.readUTF8Line(4).toString())
}
}.onFailure {
socket.close()
stopSelf(startId)
}
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
job?.cancel()
}
inner class Binder: android.os.Binder() {
fun sendMessage(message: String) {
Log.d("PUPILD", "Sending message $message")
channel.trySendBlocking(message + '\n')
}
}
private val binder = Binder()
override fun onBind(intent: Intent?): IBinder = binder
}

View File

@@ -0,0 +1,102 @@
package xyz.quaver.pupil.services
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.ServerSocket
import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import io.ktor.utils.io.writeStringUtf8
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
class TransferServerService : Service() {
private val selectorManager = SelectorManager(Dispatchers.IO)
private var serverSocket: ServerSocket? = null
private val job = Job()
private fun startForeground() = runCatching {
val notification = NotificationCompat.Builder(this, "transfer")
.setContentTitle("Pupil")
.setContentText("Transfer server is running")
.setSmallIcon(R.drawable.ic_notification)
.build()
ServiceCompat.startForeground(
this,
1,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else 0
)
}
private suspend fun handleConnection(socket: Socket) {
val readChannel = socket.openReadChannel()
val writeChannel = socket.openWriteChannel(autoFlush = true)
runCatching {
while (true) {
if (readChannel.readUTF8Line(8) == "ping") {
writeChannel.writeStringUtf8("pong\n")
}
}
}.onFailure {
socket.close()
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val address = intent?.getStringExtra("address") ?: run {
stopSelf(startId)
return START_STICKY
}
if (serverSocket != null) {
return START_STICKY
}
startForeground()
val serverSocket = aSocket(selectorManager).tcp().bind(address, 12221).also {
this@TransferServerService.serverSocket = it
}
CoroutineScope(Dispatchers.IO + job).launch {
while (true) {
Log.d("PUPILD", "Waiting for connection")
val socket = serverSocket.accept()
Log.d("PUPILD", "Accepted connection from ${socket.remoteAddress}")
launch { handleConnection(socket) }
}
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
serverSocket?.close()
}
inner class Binder: android.os.Binder() {
fun getService() = this@TransferServerService
}
private val binder = Binder()
override fun onBind(intent: Intent?): IBinder = binder
}

View File

@@ -3,7 +3,10 @@ package xyz.quaver.pupil.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.net.wifi.WpsInfo
@@ -15,11 +18,13 @@ import android.net.wifi.p2p.WifiP2pManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LiveData
@@ -27,13 +32,23 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
import xyz.quaver.pupil.receiver.WifiDirectBroadcastReceiver
import xyz.quaver.pupil.services.TransferClientService
import xyz.quaver.pupil.services.TransferServerService
import xyz.quaver.pupil.ui.fragment.TransferConnectedFragment
import xyz.quaver.pupil.ui.fragment.TransferDirectionFragment
import xyz.quaver.pupil.ui.fragment.TransferPermissionFragment
import xyz.quaver.pupil.ui.fragment.TransferSelectDataFragment
import xyz.quaver.pupil.ui.fragment.TransferTargetFragment
import xyz.quaver.pupil.ui.fragment.TransferWaitForConnectionFragment
import kotlin.coroutines.resume
@@ -66,6 +81,18 @@ class TransferActivity : AppCompatActivity(R.layout.transfer_activity) {
}
}
private var serviceBinder: TransferClientService.Binder? = null
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
serviceBinder = service as TransferClientService.Binder
}
override fun onServiceDisconnected(name: ComponentName?) {
serviceBinder = null
}
}
private fun checkPermission(force: Boolean = false): Boolean {
val permissionRequired = if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) {
Manifest.permission.ACCESS_FINE_LOCATION
@@ -110,40 +137,25 @@ class TransferActivity : AppCompatActivity(R.layout.transfer_activity) {
}
manager.connect(channel, config, object: WifiP2pManager.ActionListener {
override fun onSuccess() {
Log.d("PUPILD", "Connection successful")
}
override fun onSuccess() { }
override fun onFailure(reason: Int) {
Log.d("PUPILD", "Connection failed: $reason")
viewModel.setPeers(null)
viewModel.connect(null)
}
})
}
viewModel.connectionInfo.observe(this) { info ->
if (info == null) { return@observe }
if (info.groupFormed && info.isGroupOwner) {
// Do something
Log.d("PUPILD", "Group formed and is group owner")
Log.d("PUPILD", "Group owner IP: ${info.groupOwnerAddress.hostAddress}")
} else if (info.groupFormed) {
// Do something
Log.d("PUPILD", "Group formed")
Log.d("PUPILD", "Group owner IP: ${info.groupOwnerAddress.hostAddress}")
Log.d("PUPILD", "Local IP: ${info.groupOwnerAddress.hostAddress}")
Log.d("PUPILD", "Is group owner: ${info.isGroupOwner}")
}
}
lifecycleScope.launch {
viewModel.step.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { step ->
viewModel.messageQueue.consumeEach {
serviceBinder?.sendMessage(it)
}
viewModel.step.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).cancellable().collect step@{ step ->
Log.d("PUPILD", "Step: $step")
when (step) {
TransferStep.TARGET,
TransferStep.TARGET_FORCE -> {
if (!checkPermission(step == TransferStep.TARGET_FORCE)) {
return@collect
return@step
}
manager.discoverPeers(channel, object: WifiP2pManager.ActionListener {
@@ -156,6 +168,17 @@ class TransferActivity : AppCompatActivity(R.layout.transfer_activity) {
supportFragmentManager.commit {
replace(R.id.fragment_container_view, TransferTargetFragment())
}
val hostAddress = viewModel.connectionInfo.filterNotNull().first {
it.groupFormed
}.groupOwnerAddress.hostAddress
val intent = Intent(this@TransferActivity, TransferClientService::class.java).also {
it.putExtra("address", hostAddress)
}
bindService(intent, serviceConnection, BIND_AUTO_CREATE)
viewModel.setStep(TransferStep.SELECT_DATA)
}
TransferStep.DIRECTION -> {
supportFragmentManager.commit {
@@ -168,7 +191,8 @@ class TransferActivity : AppCompatActivity(R.layout.transfer_activity) {
}
}
TransferStep.WAIT_FOR_CONNECTION -> {
if (!checkPermission()) { return@collect }
Log.d("PUPILD", "wait for connection")
if (!checkPermission()) { return@step }
runCatching {
suspendCoroutine { continuation ->
@@ -206,6 +230,21 @@ class TransferActivity : AppCompatActivity(R.layout.transfer_activity) {
}
})
}
supportFragmentManager.commit {
replace(R.id.fragment_container_view, TransferWaitForConnectionFragment())
}
val address = viewModel.connectionInfo.filterNotNull().first {
it.groupFormed && it.isGroupOwner
}.groupOwnerAddress.hostAddress
val intent = Intent(this@TransferActivity, TransferServerService::class.java).also {
it.putExtra("address", address)
}
ContextCompat.startForegroundService(this@TransferActivity, intent)
viewModel.setStep(TransferStep.CONNECTED)
}.onFailure {
Log.e("PUPILD", "Failed to create group", it)
}
@@ -214,6 +253,16 @@ class TransferActivity : AppCompatActivity(R.layout.transfer_activity) {
replace(R.id.fragment_container_view, TransferWaitForConnectionFragment())
}
}
TransferStep.CONNECTED -> {
supportFragmentManager.commit {
replace(R.id.fragment_container_view, TransferConnectedFragment())
}
}
TransferStep.SELECT_DATA -> {
supportFragmentManager.commit {
replace(R.id.fragment_container_view, TransferSelectDataFragment())
}
}
}
}
}
@@ -236,7 +285,7 @@ class TransferActivity : AppCompatActivity(R.layout.transfer_activity) {
}
enum class TransferStep {
TARGET, TARGET_FORCE, DIRECTION, PERMISSION, WAIT_FOR_CONNECTION
TARGET, TARGET_FORCE, DIRECTION, PERMISSION, WAIT_FOR_CONNECTION, CONNECTED, SELECT_DATA
}
enum class ErrorType {
@@ -258,13 +307,16 @@ class TransferViewModel : ViewModel() {
private val _peers: MutableLiveData<WifiP2pDeviceList?> = MutableLiveData(null)
val peers: LiveData<WifiP2pDeviceList?> = _peers
private val _connectionInfo: MutableLiveData<WifiP2pInfo?> = MutableLiveData(null)
val connectionInfo: LiveData<WifiP2pInfo?> = _connectionInfo
private val _connectionInfo: MutableStateFlow<WifiP2pInfo?> = MutableStateFlow(null)
val connectionInfo: StateFlow<WifiP2pInfo?> = _connectionInfo
private val _peerToConnect: MutableLiveData<WifiP2pDevice?> = MutableLiveData(null)
val peerToConnect: LiveData<WifiP2pDevice?> = _peerToConnect
val messageQueue: Channel<String> = Channel()
fun setStep(step: TransferStep) {
Log.d("PUPILD", "Set step: $step")
_step.value = step
}
@@ -288,7 +340,11 @@ class TransferViewModel : ViewModel() {
_error.value = error
}
fun connect(device: WifiP2pDevice) {
fun connect(device: WifiP2pDevice?) {
_peerToConnect.value = device
}
fun ping() {
messageQueue.trySend("ping")
}
}

View File

@@ -0,0 +1,10 @@
package xyz.quaver.pupil.ui.fragment
import androidx.fragment.app.Fragment
import xyz.quaver.pupil.R
class TransferConnectedFragment: Fragment(R.layout.transfer_connected_fragment) {
}

View File

@@ -0,0 +1,37 @@
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import xyz.quaver.pupil.databinding.TransferSelectDataFragmentBinding
import xyz.quaver.pupil.ui.TransferViewModel
class TransferSelectDataFragment: Fragment() {
private var _binding: TransferSelectDataFragmentBinding? = null
private val binding get() = _binding!!
private val viewModel: TransferViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = TransferSelectDataFragmentBinding.inflate(inflater, container, false)
binding.checkAll.setOnCheckedChangeListener { _, isChecked ->
viewModel.ping()
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}