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