diff --git a/app/build.gradle b/app/build.gradle
index e87d894f..0db13db4 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -38,7 +38,7 @@ android {
compileSdk 34
targetSdkVersion 34
versionCode 69
- versionName "5.3.13"
+ versionName "5.3.14"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
@@ -82,13 +82,14 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2"
implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.3.2"
-
+
+ implementation "androidx.core:core-ktx:1.12.0"
implementation "androidx.appcompat:appcompat:1.4.1"
implementation "androidx.activity:activity-ktx:1.4.0"
implementation "androidx.fragment:fragment-ktx:1.4.1"
implementation "androidx.preference:preference-ktx:1.2.0"
implementation "androidx.recyclerview:recyclerview:1.2.1"
- implementation "androidx.constraintlayout:constraintlayout:2.1.3"
+ implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.gridlayout:gridlayout:1.0.0"
implementation "androidx.biometric:biometric:1.1.0"
implementation "androidx.work:work-runtime-ktx:2.7.1"
@@ -116,6 +117,7 @@ dependencies {
//noinspection GradleDependency
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
+ implementation "io.ktor:ktor-network:2.3.10"
implementation "com.tbuonomo.andrui:viewpagerdotsindicator:4.1.2"
@@ -127,6 +129,8 @@ dependencies {
implementation "ru.noties.markwon:core:3.1.0"
+ implementation "com.skyfishjy.ripplebackground:library:1.0.1"
+
implementation "org.jsoup:jsoup:1.14.3"
implementation "xyz.quaver:documentfilex:0.7.2"
diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json
index 67f8afd5..79772ba8 100644
--- a/app/release/output-metadata.json
+++ b/app/release/output-metadata.json
@@ -12,7 +12,7 @@
"filters": [],
"attributes": [],
"versionCode": 69,
- "versionName": "5.3.12",
+ "versionName": "5.3.14",
"outputFile": "app-release.apk"
}
],
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5b07b068..0f98c2f7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,11 +8,17 @@
-
-
+
+
+
+
+
+
@@ -48,7 +54,15 @@
+ android:foregroundServiceType="dataSync" />
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/Pupil.kt b/app/src/main/java/xyz/quaver/pupil/Pupil.kt
index 0bf2a719..d91450e9 100644
--- a/app/src/main/java/xyz/quaver/pupil/Pupil.kt
+++ b/app/src/main/java/xyz/quaver/pupil/Pupil.kt
@@ -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("dark_mode")) {
diff --git a/app/src/main/java/xyz/quaver/pupil/adapters/TransferPeersAdapter.kt b/app/src/main/java/xyz/quaver/pupil/adapters/TransferPeersAdapter.kt
new file mode 100644
index 00000000..4b95d64a
--- /dev/null
+++ b/app/src/main/java/xyz/quaver/pupil/adapters/TransferPeersAdapter.kt
@@ -0,0 +1,44 @@
+package xyz.quaver.pupil.adapters
+
+import android.net.wifi.p2p.WifiP2pDevice
+import android.net.wifi.p2p.WifiP2pDeviceList
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.BaseAdapter
+import androidx.recyclerview.widget.RecyclerView
+import xyz.quaver.pupil.R
+import xyz.quaver.pupil.databinding.TransferPeerListItemBinding
+
+class TransferPeersAdapter(
+ private val devices: Collection,
+ private val onDeviceSelected: (WifiP2pDevice) -> Unit
+): RecyclerView.Adapter() {
+
+ class ViewHolder(val binding: TransferPeerListItemBinding): RecyclerView.ViewHolder(binding.root)
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val binding = TransferPeerListItemBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ return ViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val device = devices.elementAt(position)
+
+ holder.binding.deviceName.text = device.deviceName
+ holder.binding.deviceAddress.text = device.deviceAddress
+
+ holder.binding.root.setOnClickListener {
+ onDeviceSelected(device)
+ }
+ }
+
+ override fun getItemCount(): Int {
+ return devices.size
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/receiver/WifiDirectBroadcastReceiver.kt b/app/src/main/java/xyz/quaver/pupil/receiver/WifiDirectBroadcastReceiver.kt
new file mode 100644
index 00000000..55eebdd3
--- /dev/null
+++ b/app/src/main/java/xyz/quaver/pupil/receiver/WifiDirectBroadcastReceiver.kt
@@ -0,0 +1,59 @@
+package xyz.quaver.pupil.receiver
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.wifi.p2p.WifiP2pManager
+import android.os.Build
+import android.os.Parcelable
+import android.util.Log
+import androidx.core.app.ActivityCompat
+import xyz.quaver.pupil.ui.ErrorType
+import xyz.quaver.pupil.ui.TransferStep
+import xyz.quaver.pupil.ui.TransferViewModel
+
+private inline fun Intent.getParcelableExtraCompat(key: String): T? = when {
+ Build.VERSION.SDK_INT >= 33 -> getParcelableExtra(key, T::class.java)
+ else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T
+}
+
+class WifiDirectBroadcastReceiver(
+ private val manager: WifiP2pManager,
+ private val channel: WifiP2pManager.Channel,
+ private val viewModel: TransferViewModel
+): BroadcastReceiver() {
+ @SuppressLint("MissingPermission")
+ override fun onReceive(context: Context?, intent: Intent?) {
+ context!!
+ when (intent?.action) {
+ WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
+ val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)
+ viewModel.setWifiP2pEnabled(state == WifiP2pManager.WIFI_P2P_STATE_ENABLED)
+ }
+ WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> {
+ manager.requestPeers(channel) { peers ->
+ viewModel.setPeers(peers)
+ }
+ }
+ WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
+ // Respond to new connection or disconnections
+ val networkInfo = intent.getParcelableExtraCompat(WifiP2pManager.EXTRA_NETWORK_INFO)
+
+ if (networkInfo?.isConnected == true) {
+ manager.requestConnectionInfo(channel) { info ->
+ viewModel.setConnectionInfo(info)
+ }
+ } else {
+ viewModel.setConnectionInfo(null)
+ }
+ }
+ WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> {
+ // Respond to this device's wifi state changing
+ viewModel.setThisDevice(intent.getParcelableExtraCompat(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt b/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt
index 54bf595e..d1082511 100644
--- a/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt
+++ b/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt
@@ -226,7 +226,6 @@ class DownloadService : Service() {
}
override fun onResponse(call: Call, response: Response) {
- Log.d("PUPILD", "ONRESPONSE ${call.request().tag()}")
val (galleryID, index, startId) = call.request().tag() as Tag
val ext = call.request().url().encodedPath().split('.').last()
@@ -344,7 +343,7 @@ class DownloadService : Service() {
return@launch
}
- notification[galleryID]?.setContentTitle(galleryInfo.title?.ellipsize(30))
+ notification[galleryID]?.setContentTitle(galleryInfo.title.ellipsize(32))
notify(galleryID)
val queued = mutableSetOf()
@@ -409,7 +408,7 @@ class DownloadService : Service() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
startForeground(R.id.downloader_notification_id, serviceNotification.build())
} else {
- startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
+ startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
when (intent?.getStringExtra(KEY_COMMAND)) {
@@ -434,7 +433,7 @@ class DownloadService : Service() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
startForeground(R.id.downloader_notification_id, serviceNotification.build())
} else {
- startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
+ startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
interceptors[Tag::class] = interceptor
}
diff --git a/app/src/main/java/xyz/quaver/pupil/services/TransferClientService.kt b/app/src/main/java/xyz/quaver/pupil/services/TransferClientService.kt
new file mode 100644
index 00000000..243ab320
--- /dev/null
+++ b/app/src/main/java/xyz/quaver/pupil/services/TransferClientService.kt
@@ -0,0 +1,120 @@
+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 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 kotlinx.coroutines.withTimeout
+import xyz.quaver.pupil.R
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+class TransferClientService : Service() {
+ private val selectorManager = SelectorManager(Dispatchers.IO)
+ private val channel = Channel>>()
+ 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 {
+ TransferPacket.Hello().writeToChannel(writeChannel)
+ val handshake = TransferPacket.readFromChannel(readChannel)
+
+ if (handshake !is TransferPacket.Hello || handshake.version != TRANSFER_PROTOCOL_VERSION) {
+ throw IllegalStateException("Invalid handshake")
+ }
+
+ while (true) {
+ val (packet, continuation) = channel.receive()
+
+ Log.d("PUPILD", "Sending packet $packet")
+
+ packet.writeToChannel(writeChannel)
+
+ val response = TransferPacket.readFromChannel(readChannel).also {
+ Log.d("PUPILD", "Received packet $it")
+ }
+
+ continuation.resume(response)
+ }
+ }.onFailure {
+ Log.d("PUPILD", "Connection closed with error $it")
+ socket.close()
+ stopSelf(startId)
+ }
+ }
+
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ job?.cancel()
+ }
+
+ inner class Binder: android.os.Binder() {
+
+
+ suspend fun sendPacket(packet: TransferPacket): Result = runCatching {
+ val response = withTimeout(1000) { suspendCoroutine { continuation ->
+ channel.trySendBlocking(packet to continuation)
+ } }
+
+ response as? TransferPacket.ListResponse ?: throw IllegalStateException("Invalid response")
+ }
+ }
+
+ private val binder = Binder()
+
+ override fun onBind(intent: Intent?): IBinder = binder
+}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/services/TransferProtocol.kt b/app/src/main/java/xyz/quaver/pupil/services/TransferProtocol.kt
new file mode 100644
index 00000000..264e1886
--- /dev/null
+++ b/app/src/main/java/xyz/quaver/pupil/services/TransferProtocol.kt
@@ -0,0 +1,94 @@
+package xyz.quaver.pupil.services
+
+import io.ktor.utils.io.ByteReadChannel
+import io.ktor.utils.io.ByteWriteChannel
+
+const val TRANSFER_PROTOCOL_VERSION: UByte = 1u
+
+enum class TransferType(val value: UByte) {
+ INVALID(255u),
+ HELLO(0u),
+ PING(1u),
+ PONG(2u),
+ LIST_REQUEST(3u),
+ LIST_RESPONSE(4u),
+}
+
+sealed interface TransferPacket {
+ val type: TransferType
+
+ suspend fun writeToChannel(channel: ByteWriteChannel)
+
+ data class Hello(val version: UByte = TRANSFER_PROTOCOL_VERSION): TransferPacket {
+ override val type = TransferType.HELLO
+
+ override suspend fun writeToChannel(channel: ByteWriteChannel) {
+ channel.writeByte(type.value.toByte())
+ channel.writeByte(version.toByte())
+ }
+ }
+
+ data object Ping: TransferPacket {
+ override val type = TransferType.PING
+ override suspend fun writeToChannel(channel: ByteWriteChannel) {
+ channel.writeByte(type.value.toByte())
+ }
+ }
+
+ data object Pong: TransferPacket {
+ override val type = TransferType.PONG
+ override suspend fun writeToChannel(channel: ByteWriteChannel) {
+ channel.writeByte(type.value.toByte())
+ }
+ }
+
+ data object ListRequest: TransferPacket {
+ override val type = TransferType.LIST_REQUEST
+ override suspend fun writeToChannel(channel: ByteWriteChannel) {
+ channel.writeByte(type.value.toByte())
+ }
+ }
+
+ data object Invalid: TransferPacket {
+ override val type = TransferType.INVALID
+ override suspend fun writeToChannel(channel: ByteWriteChannel) {
+ channel.writeByte(type.value.toByte())
+ }
+ }
+
+ data class ListResponse(
+ val favoritesCount: Int,
+ val historyCount: Int,
+ val downloadsCount: Int,
+ ): TransferPacket {
+ override val type = TransferType.LIST_RESPONSE
+
+ override suspend fun writeToChannel(channel: ByteWriteChannel) {
+ channel.writeByte(type.value.toByte())
+ channel.writeInt(favoritesCount)
+ channel.writeInt(historyCount)
+ channel.writeInt(downloadsCount)
+ }
+ }
+
+ companion object {
+ suspend fun readFromChannel(channel: ByteReadChannel): TransferPacket {
+ return when(val type = channel.readByte().toUByte()) {
+ TransferType.HELLO.value -> {
+ val version = channel.readByte().toUByte()
+ Hello(version)
+ }
+ TransferType.PING.value -> Ping
+ TransferType.PONG.value -> Pong
+ TransferType.LIST_REQUEST.value -> ListRequest
+ TransferType.LIST_RESPONSE.value -> {
+ val favoritesCount = channel.readInt()
+ val historyCount = channel.readInt()
+ val downloadsCount = channel.readInt()
+ ListResponse(favoritesCount, historyCount, downloadsCount)
+ }
+ else -> throw IllegalArgumentException("Unknown packet type: $type")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/services/TransferServerService.kt b/app/src/main/java/xyz/quaver/pupil/services/TransferServerService.kt
new file mode 100644
index 00000000..a1187159
--- /dev/null
+++ b/app/src/main/java/xyz/quaver/pupil/services/TransferServerService.kt
@@ -0,0 +1,123 @@
+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 kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.launch
+import xyz.quaver.pupil.R
+import xyz.quaver.pupil.favorites
+import xyz.quaver.pupil.histories
+import xyz.quaver.pupil.util.downloader.DownloadManager
+
+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 fun generateListResponse(): TransferPacket.ListResponse {
+ val favoritesCount = favorites.size
+ val historyCount = histories.size
+ val downloadsCount = DownloadManager.getInstance(this).downloadFolderMap.size
+ return TransferPacket.ListResponse(favoritesCount, historyCount, downloadsCount)
+ }
+
+ private suspend fun handleConnection(socket: Socket) {
+ val readChannel = socket.openReadChannel()
+ val writeChannel = socket.openWriteChannel(autoFlush = true)
+
+ runCatching {
+ while (true) {
+ val packet = TransferPacket.readFromChannel(readChannel)
+
+ Log.d("PUPILD", "Received packet $packet")
+
+ binder.channel.trySend(packet)
+
+ val response = when (packet) {
+ is TransferPacket.Hello -> TransferPacket.Hello()
+ is TransferPacket.Ping -> TransferPacket.Pong
+ is TransferPacket.ListRequest -> generateListResponse()
+ else -> TransferPacket.Invalid
+ }
+
+ response.writeToChannel(writeChannel)
+ }
+ }.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() {
+ val channel = Channel()
+ }
+
+ private val binder = Binder()
+
+ override fun onBind(intent: Intent?): IBinder = binder
+}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/ui/TransferActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/TransferActivity.kt
new file mode 100644
index 00000000..ca04e026
--- /dev/null
+++ b/app/src/main/java/xyz/quaver/pupil/ui/TransferActivity.kt
@@ -0,0 +1,379 @@
+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
+import android.net.wifi.p2p.WifiP2pConfig
+import android.net.wifi.p2p.WifiP2pDevice
+import android.net.wifi.p2p.WifiP2pDeviceList
+import android.net.wifi.p2p.WifiP2pInfo
+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
+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.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.TransferPacket
+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
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+
+class TransferActivity : AppCompatActivity(R.layout.transfer_activity) {
+
+ private val viewModel: TransferViewModel by viewModels()
+
+ private val intentFilter = IntentFilter().apply {
+ addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
+ addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
+ addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
+ addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION)
+ }
+
+ private lateinit var manager: WifiP2pManager
+ private lateinit var channel: WifiP2pManager.Channel
+
+ private var receiver: BroadcastReceiver? = null
+
+ private val requestPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ if (granted) {
+ viewModel.setStep(TransferStep.TARGET)
+ } else {
+ viewModel.setStep(TransferStep.PERMISSION)
+ }
+ }
+
+ private var clientServiceBinder: TransferClientService.Binder? = null
+
+ private val clientServiceConnection = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+ clientServiceBinder = service as TransferClientService.Binder
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ clientServiceBinder = null
+ }
+ }
+
+ private fun checkPermission(force: Boolean = false): Boolean {
+ val permissionRequired = if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) {
+ Manifest.permission.ACCESS_FINE_LOCATION
+ } else {
+ Manifest.permission.NEARBY_WIFI_DEVICES
+ }
+
+ val permissionGranted =
+ ActivityCompat.checkSelfPermission(this, permissionRequired) == PackageManager.PERMISSION_GRANTED
+
+ val shouldShowRationale =
+ ActivityCompat.shouldShowRequestPermissionRationale(this, permissionRequired)
+
+ if (!permissionGranted) {
+ if (shouldShowRationale && force) {
+ viewModel.setStep(TransferStep.PERMISSION)
+ } else {
+ requestPermissionLauncher.launch(permissionRequired)
+ }
+ return false
+ }
+
+ return true
+ }
+
+ private fun handleServerResponse(response: TransferPacket?) {
+ when (response) {
+ is TransferPacket.ListResponse -> {
+ Log.d("PUPILD", "Received list response $response")
+ }
+ else -> {
+ Log.d("PUPILD", "Received invalid response $response")
+ }
+ }
+ }
+
+ @SuppressLint("SourceLockedOrientationActivity")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ supportActionBar?.hide()
+ requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+
+ manager = getSystemService(WIFI_P2P_SERVICE) as WifiP2pManager
+ channel = manager.initialize(this, mainLooper, null)
+
+ viewModel.peerToConnect.observe(this) { peer ->
+ if (peer == null) { return@observe }
+ if (!checkPermission()) { return@observe }
+
+ val config = WifiP2pConfig().apply {
+ deviceAddress = peer.deviceAddress
+ wps.setup = WpsInfo.PBC
+ }
+
+ manager.connect(channel, config, object: WifiP2pManager.ActionListener {
+ override fun onSuccess() { }
+
+ override fun onFailure(reason: Int) {
+ viewModel.connect(null)
+ }
+ })
+ }
+ lifecycleScope.launch {
+ viewModel.messageQueue.consumeEach {
+ clientServiceBinder?.sendPacket(it)?.getOrNull()?.let(::handleServerResponse)
+ }
+ }
+
+ lifecycleScope.launch {
+ viewModel.step.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collectLatest step@{ step ->
+ when (step) {
+ TransferStep.TARGET,
+ TransferStep.TARGET_FORCE -> {
+ if (!checkPermission(step == TransferStep.TARGET_FORCE)) {
+ return@step
+ }
+
+ manager.discoverPeers(channel, object: WifiP2pManager.ActionListener {
+ override fun onSuccess() { }
+
+ override fun onFailure(reason: Int) {
+ }
+ })
+
+ 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)
+ }
+ ContextCompat.startForegroundService(this@TransferActivity, intent)
+ bindService(intent, clientServiceConnection, BIND_AUTO_CREATE)
+
+ viewModel.setStep(TransferStep.SELECT_DATA)
+ }
+ TransferStep.DIRECTION -> {
+ supportFragmentManager.commit {
+ replace(R.id.fragment_container_view, TransferDirectionFragment())
+ }
+ }
+ TransferStep.PERMISSION -> {
+ supportFragmentManager.commit {
+ replace(R.id.fragment_container_view, TransferPermissionFragment())
+ }
+ }
+ TransferStep.WAIT_FOR_CONNECTION -> {
+ Log.d("PUPILD", "wait for connection")
+ if (!checkPermission()) { return@step }
+
+ runCatching {
+ suspendCoroutine { continuation ->
+ manager.removeGroup(channel, object: WifiP2pManager.ActionListener {
+ override fun onSuccess() {
+ continuation.resume(Unit)
+ }
+
+ override fun onFailure(reason: Int) {
+ continuation.resume(Unit)
+ }
+ })
+ }
+
+ suspendCoroutine { continuation ->
+ manager.cancelConnect(channel, object: WifiP2pManager.ActionListener {
+ override fun onSuccess() {
+ continuation.resume(Unit)
+ }
+
+ override fun onFailure(reason: Int) {
+ continuation.resume(Unit)
+ }
+ })
+ }
+
+ suspendCoroutine { continuation ->
+ manager.createGroup(channel, object: WifiP2pManager.ActionListener {
+ override fun onSuccess() {
+ continuation.resume(Unit)
+ }
+
+ override fun onFailure(reason: Int) {
+ continuation.resumeWithException(Exception("Failed to create group $reason"))
+ }
+ })
+ }
+
+ 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)
+ val binder: TransferServerService.Binder = suspendCoroutine { continuation ->
+ bindService(intent, object: ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+ continuation.resume(service as TransferServerService.Binder)
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) { }
+ }, BIND_AUTO_CREATE)
+ }
+ binder.channel.receive()
+
+ viewModel.setStep(TransferStep.CONNECTED)
+ }.onFailure {
+ Log.e("PUPILD", "Failed to create group", it)
+ }
+
+ supportFragmentManager.commit {
+ 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())
+ }
+ }
+ }
+ }
+ }
+
+ }
+
+ override fun onResume() {
+ super.onResume()
+ bindService(Intent(this, TransferClientService::class.java), clientServiceConnection, BIND_AUTO_CREATE)
+ WifiDirectBroadcastReceiver(manager, channel, viewModel).also {
+ receiver = it
+ registerReceiver(it, intentFilter)
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ unbindService(clientServiceConnection)
+ receiver?.let { unregisterReceiver(it) }
+ receiver = null
+ }
+}
+
+enum class TransferStep {
+ TARGET, TARGET_FORCE, DIRECTION, PERMISSION, WAIT_FOR_CONNECTION, CONNECTED, SELECT_DATA
+}
+
+enum class ErrorType {
+}
+
+class TransferViewModel : ViewModel() {
+ private val _step: MutableStateFlow = MutableStateFlow(TransferStep.DIRECTION)
+ val step: StateFlow = _step
+
+ private val _error = MutableLiveData(null)
+ val error: LiveData = _error
+
+ private val _wifiP2pEnabled: MutableLiveData = MutableLiveData(false)
+ val wifiP2pEnabled: LiveData = _wifiP2pEnabled
+
+ private val _thisDevice: MutableLiveData = MutableLiveData(null)
+ val thisDevice: LiveData = _thisDevice
+
+ private val _peers: MutableLiveData = MutableLiveData(null)
+ val peers: LiveData = _peers
+
+ private val _connectionInfo: MutableStateFlow = MutableStateFlow(null)
+ val connectionInfo: StateFlow = _connectionInfo
+
+ private val _peerToConnect: MutableLiveData = MutableLiveData(null)
+ val peerToConnect: LiveData = _peerToConnect
+
+ val messageQueue: Channel = Channel()
+
+ fun setStep(step: TransferStep) {
+ Log.d("PUPILD", "Set step: $step")
+ _step.value = step
+ }
+
+ fun setWifiP2pEnabled(enabled: Boolean) {
+ _wifiP2pEnabled.value = enabled
+ }
+
+ fun setThisDevice(device: WifiP2pDevice?) {
+ _thisDevice.value = device
+ }
+
+ fun setPeers(peers: WifiP2pDeviceList?) {
+ _peers.value = peers
+ }
+
+ fun setConnectionInfo(info: WifiP2pInfo?) {
+ _connectionInfo.value = info
+ }
+
+ fun setError(error: ErrorType?) {
+ _error.value = error
+ }
+
+ fun connect(device: WifiP2pDevice?) {
+ _peerToConnect.value = device
+ }
+
+ fun ping() {
+ messageQueue.trySend(TransferPacket.Ping)
+ }
+
+ fun list() {
+ messageQueue.trySend(TransferPacket.ListRequest)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/ui/fragment/SettingsFragment.kt b/app/src/main/java/xyz/quaver/pupil/ui/fragment/SettingsFragment.kt
index 97706f8b..e1cd9a30 100644
--- a/app/src/main/java/xyz/quaver/pupil/ui/fragment/SettingsFragment.kt
+++ b/app/src/main/java/xyz/quaver/pupil/ui/fragment/SettingsFragment.kt
@@ -21,12 +21,14 @@ package xyz.quaver.pupil.ui.fragment
import android.app.Activity
import android.content.*
import android.os.Bundle
+import android.util.Log
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.*
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
import com.google.firebase.crashlytics.FirebaseCrashlytics
+import com.google.firebase.crashlytics.internal.model.CrashlyticsReport
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -40,6 +42,7 @@ import xyz.quaver.pupil.clientHolder
import xyz.quaver.pupil.types.SendLogException
import xyz.quaver.pupil.ui.LockActivity
import xyz.quaver.pupil.ui.SettingsActivity
+import xyz.quaver.pupil.ui.TransferActivity
import xyz.quaver.pupil.ui.dialog.*
import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.DownloadManager
@@ -113,6 +116,9 @@ class SettingsFragment :
)
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
+ "transfer_data" -> {
+ activity?.startActivity(Intent(activity, TransferActivity::class.java))
+ }
else -> return false
}
}
@@ -300,6 +306,9 @@ class SettingsFragment :
true
}
}
+ "transfer_data" -> {
+ onPreferenceClickListener = this@SettingsFragment
+ }
}
}
diff --git a/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferConnectedFragment.kt b/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferConnectedFragment.kt
new file mode 100644
index 00000000..a434d078
--- /dev/null
+++ b/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferConnectedFragment.kt
@@ -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) {
+
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferDirectionFragment.kt b/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferDirectionFragment.kt
new file mode 100644
index 00000000..27584eda
--- /dev/null
+++ b/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferDirectionFragment.kt
@@ -0,0 +1,44 @@
+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.R
+import xyz.quaver.pupil.databinding.TransferDirectionFragmentBinding
+import xyz.quaver.pupil.ui.TransferStep
+import xyz.quaver.pupil.ui.TransferViewModel
+
+class TransferDirectionFragment : Fragment(R.layout.transfer_direction_fragment) {
+
+ private var _binding: TransferDirectionFragmentBinding? = null
+ private val binding get() = _binding!!
+
+ private val viewModel: TransferViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = TransferDirectionFragmentBinding.inflate(inflater, container, false)
+
+ binding.inButton.setOnClickListener {
+ viewModel.setStep(TransferStep.TARGET)
+ }
+
+ binding.outButton.setOnClickListener {
+ viewModel.setStep(TransferStep.WAIT_FOR_CONNECTION)
+ }
+
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferPermissionFragment.kt b/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferPermissionFragment.kt
new file mode 100644
index 00000000..e6155814
--- /dev/null
+++ b/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferPermissionFragment.kt
@@ -0,0 +1,38 @@
+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.TransferPermissionFragmentBinding
+import xyz.quaver.pupil.ui.TransferStep
+import xyz.quaver.pupil.ui.TransferViewModel
+
+class TransferPermissionFragment: Fragment() {
+
+ private var _binding: TransferPermissionFragmentBinding? = null
+ private val binding get() = _binding!!
+
+ private val viewModel: TransferViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = TransferPermissionFragmentBinding.inflate(inflater, container, false)
+
+ binding.permissionsButton.setOnClickListener {
+ viewModel.setStep(TransferStep.TARGET_FORCE)
+ }
+
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferSelectDataFragment.kt b/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferSelectDataFragment.kt
new file mode 100644
index 00000000..727089bb
--- /dev/null
+++ b/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferSelectDataFragment.kt
@@ -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.list()
+ }
+
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferTargetFragment.kt b/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferTargetFragment.kt
new file mode 100644
index 00000000..70584dba
--- /dev/null
+++ b/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferTargetFragment.kt
@@ -0,0 +1,69 @@
+package xyz.quaver.pupil.ui.fragment
+
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ArrayAdapter
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import xyz.quaver.pupil.R
+import xyz.quaver.pupil.adapters.TransferPeersAdapter
+import xyz.quaver.pupil.databinding.TransferTargetFragmentBinding
+import xyz.quaver.pupil.ui.TransferStep
+import xyz.quaver.pupil.ui.TransferViewModel
+
+class TransferTargetFragment : Fragment() {
+
+ private var _binding: TransferTargetFragmentBinding? = null
+ private val binding get() = _binding!!
+
+ private val viewModel: TransferViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = TransferTargetFragmentBinding.inflate(inflater, container, false)
+
+ viewModel.thisDevice.observe(viewLifecycleOwner) { device ->
+ if (device == null) {
+ return@observe
+ }
+
+ if (device.status == 3) {
+ binding.ripple.startRippleAnimation()
+ binding.retryButton.visibility = View.INVISIBLE
+ } else {
+ binding.ripple.stopRippleAnimation()
+ binding.retryButton.visibility = View.VISIBLE
+ }
+ }
+
+ viewModel.peers.observe(viewLifecycleOwner) { peers ->
+ if (peers == null) {
+ return@observe
+ }
+
+ binding.deviceList.adapter = TransferPeersAdapter(peers.deviceList) {
+ viewModel.connect(it)
+ }
+ }
+
+ binding.ripple.startRippleAnimation()
+
+ binding.retryButton.setOnClickListener {
+ viewModel.setStep(TransferStep.TARGET)
+ }
+
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferWaitForConnectionFragment.kt b/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferWaitForConnectionFragment.kt
new file mode 100644
index 00000000..41f81cc4
--- /dev/null
+++ b/app/src/main/java/xyz/quaver/pupil/ui/fragment/TransferWaitForConnectionFragment.kt
@@ -0,0 +1,32 @@
+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 xyz.quaver.pupil.databinding.TransferWaitForConnectionFragmentBinding
+
+class TransferWaitForConnectionFragment : Fragment() {
+
+ private var _binding: TransferWaitForConnectionFragmentBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ _binding = TransferWaitForConnectionFragmentBinding.inflate(layoutInflater)
+
+ binding.ripple.startRippleAnimation()
+
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadManager.kt b/app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadManager.kt
index ed8f088f..a696b4f2 100644
--- a/app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadManager.kt
+++ b/app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadManager.kt
@@ -20,6 +20,7 @@ package xyz.quaver.pupil.util.downloader
import android.content.Context
import android.content.ContextWrapper
+import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -28,7 +29,10 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Call
import xyz.quaver.io.FileX
-import xyz.quaver.io.util.*
+import xyz.quaver.io.util.deleteRecursively
+import xyz.quaver.io.util.getChild
+import xyz.quaver.io.util.readText
+import xyz.quaver.io.util.writeText
import xyz.quaver.pupil.client
import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.util.Preferences
diff --git a/app/src/main/java/xyz/quaver/pupil/util/misc.kt b/app/src/main/java/xyz/quaver/pupil/util/misc.kt
index 9e666680..327ba3f8 100644
--- a/app/src/main/java/xyz/quaver/pupil/util/misc.kt
+++ b/app/src/main/java/xyz/quaver/pupil/util/misc.kt
@@ -24,6 +24,7 @@ import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
+import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
@@ -125,11 +126,32 @@ suspend fun GalleryInfo.getRequestBuilders(): List {
}
}
-fun String.ellipsize(n: Int): String =
- if (this.length > n)
- this.slice(0 until n) + "…"
- else
- this
+fun byteCount(codePoint: Int): Int = when (codePoint) {
+ in 0 ..< 0x80 -> 1
+ in 0x80 ..< 0x800 -> 2
+ in 0x800 ..< 0x10000 -> 3
+ in 0x10000 ..< 0x110000 -> 4
+ else -> 0
+}
+
+fun String.ellipsize(n: Int): String = buildString {
+ var count = 0
+ var index = 0
+ val codePointLength = this@ellipsize.codePointCount(0, this@ellipsize.length)
+
+ while (index < codePointLength) {
+ val nextCodePoint = this@ellipsize.codePointAt(index)
+ val nextByte = byteCount(nextCodePoint)
+ if (count + nextByte > 124) {
+ append("…")
+ break
+ }
+ appendCodePoint(nextCodePoint)
+ count += nextByte
+ index++
+ }
+
+}
operator fun JsonElement.get(index: Int) =
this.jsonArray[index]
diff --git a/app/src/main/res/drawable/arrow.xml b/app/src/main/res/drawable/arrow.xml
new file mode 100644
index 00000000..f4eda35b
--- /dev/null
+++ b/app/src/main/res/drawable/arrow.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/drawable/check.xml b/app/src/main/res/drawable/check.xml
new file mode 100644
index 00000000..d075199a
--- /dev/null
+++ b/app/src/main/res/drawable/check.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/drawable/download.xml b/app/src/main/res/drawable/download.xml
new file mode 100644
index 00000000..3f16b5b8
--- /dev/null
+++ b/app/src/main/res/drawable/download.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/drawable/heart.xml b/app/src/main/res/drawable/heart.xml
new file mode 100644
index 00000000..07d57bd0
--- /dev/null
+++ b/app/src/main/res/drawable/heart.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/drawable/history_rounded.xml b/app/src/main/res/drawable/history_rounded.xml
new file mode 100644
index 00000000..b47b45d1
--- /dev/null
+++ b/app/src/main/res/drawable/history_rounded.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/link.xml b/app/src/main/res/drawable/link.xml
new file mode 100644
index 00000000..8e1158ad
--- /dev/null
+++ b/app/src/main/res/drawable/link.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/drawable/round_button.xml b/app/src/main/res/drawable/round_button.xml
new file mode 100644
index 00000000..b167415e
--- /dev/null
+++ b/app/src/main/res/drawable/round_button.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/transfer_device.xml b/app/src/main/res/drawable/transfer_device.xml
new file mode 100644
index 00000000..f202c9ab
--- /dev/null
+++ b/app/src/main/res/drawable/transfer_device.xml
@@ -0,0 +1,12 @@
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/transfer_ripple.xml b/app/src/main/res/drawable/transfer_ripple.xml
new file mode 100644
index 00000000..db299b54
--- /dev/null
+++ b/app/src/main/res/drawable/transfer_ripple.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/warning.xml b/app/src/main/res/drawable/warning.xml
new file mode 100644
index 00000000..c5cbb208
--- /dev/null
+++ b/app/src/main/res/drawable/warning.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/layout/transfer_activity.xml b/app/src/main/res/layout/transfer_activity.xml
new file mode 100644
index 00000000..9ddc1a47
--- /dev/null
+++ b/app/src/main/res/layout/transfer_activity.xml
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/transfer_connected_fragment.xml b/app/src/main/res/layout/transfer_connected_fragment.xml
new file mode 100644
index 00000000..12d1ffee
--- /dev/null
+++ b/app/src/main/res/layout/transfer_connected_fragment.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/transfer_direction_fragment.xml b/app/src/main/res/layout/transfer_direction_fragment.xml
new file mode 100644
index 00000000..3089f3fc
--- /dev/null
+++ b/app/src/main/res/layout/transfer_direction_fragment.xml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/transfer_peer_list_item.xml b/app/src/main/res/layout/transfer_peer_list_item.xml
new file mode 100644
index 00000000..6f576f5c
--- /dev/null
+++ b/app/src/main/res/layout/transfer_peer_list_item.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/transfer_permission_fragment.xml b/app/src/main/res/layout/transfer_permission_fragment.xml
new file mode 100644
index 00000000..7f6ceb72
--- /dev/null
+++ b/app/src/main/res/layout/transfer_permission_fragment.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/transfer_select_data_fragment.xml b/app/src/main/res/layout/transfer_select_data_fragment.xml
new file mode 100644
index 00000000..a750e826
--- /dev/null
+++ b/app/src/main/res/layout/transfer_select_data_fragment.xml
@@ -0,0 +1,196 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/transfer_target_fragment.xml b/app/src/main/res/layout/transfer_target_fragment.xml
new file mode 100644
index 00000000..25a94a12
--- /dev/null
+++ b/app/src/main/res/layout/transfer_target_fragment.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/transfer_wait_for_connection_fragment.xml b/app/src/main/res/layout/transfer_wait_for_connection_fragment.xml
new file mode 100644
index 00000000..8fdd34e2
--- /dev/null
+++ b/app/src/main/res/layout/transfer_wait_for_connection_fragment.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index ea44ad2f..3a318552 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -48,6 +48,7 @@
ダウンロード
ページ移動
現ページ番号: %1$d\nページ数: %2$d
+ 転送
hitomi.laに接続できません
%1$dページへ移動
ダウンロード削除
@@ -159,4 +160,6 @@
アンドロイド11以上では外部からのアプリ内部空間接近が不可能です。ダウンロードフォルダを変更しますか?
ネットワーク
ダウンロードデータベースを再構築
+ 他の機器にデータを転送
+ 他の機器へのデータ転送の進捗度を表示
\ No newline at end of file
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 8554abb8..4edcfafc 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -47,6 +47,7 @@
다운로드
페이지 이동
현재 페이지: %1$d\n페이지 수: %2$d
+ 전송
hitomi.la에 연결할 수 없습니다
%1$d 페이지로 이동
다운로드 삭제
@@ -159,4 +160,6 @@
안드로이드 11 이상에서는 외부에서 현재 다운로드 폴더에 접근할 수 없습니다. 변경하시겠습니까?
네트워크
다운로드 데이터베이스 복구
+ 다른 기기에 데이터 전송
+ 다른 기기에 데이터 전송 시 상태 표시
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c2cc2bbf..87164ae8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -43,6 +43,9 @@
Update
Shows update progress
+ Transfer
+ Shows progress of transferring data to another device
+
Unable to connect to hitomi.la
Lock file corrupted! Please re-install Pupil
@@ -174,6 +177,7 @@
Hide image from gallery
Low quality images
Load low quality images to improve load speed and data usage
+ Transfer data to another device
diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml
index de11f055..1be8aabf 100644
--- a/app/src/main/res/xml/root_preferences.xml
+++ b/app/src/main/res/xml/root_preferences.xml
@@ -52,6 +52,10 @@
app:defaultValue="8"
app:useSimpleSummaryProvider="true"/>
+
+
+
+