commit 8d2c6ca504f7bc7505b5ec43a421c079f9b85e7b Author: lily Date: Wed Aug 5 15:12:54 2026 -0400 First Commit With Working app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7672c51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle/ +/local.properties +.idea/ +.DS_Store +/build +/app/build +/captures +.externalNativeBuild +.cxx +/receiver/receiver diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..2c40431 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.example.filedrop" + compileSdk = 36 + + defaultConfig { + applicationId = "com.example.filedrop" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlin { + jvmToolchain(21) + } + + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.datastore.preferences) + implementation(libs.okhttp) + debugImplementation(libs.androidx.ui.tooling) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..64d529c --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,5 @@ +# Keep OkHttp platform-specific classes referenced reflectively. +-dontwarn okhttp3.internal.platform.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e76fb5a --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/filedrop/MainActivity.kt b/app/src/main/java/com/example/filedrop/MainActivity.kt new file mode 100644 index 0000000..aa5124a --- /dev/null +++ b/app/src/main/java/com/example/filedrop/MainActivity.kt @@ -0,0 +1,197 @@ +package com.example.filedrop + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import android.os.Build + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + AppTheme { + FileDropScreen() + } + } + } +} + +@Composable +private fun AppTheme(content: @Composable () -> Unit) { + val dark = isSystemInDarkTheme() + val context = LocalContext.current + val scheme = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> + if (dark) dynamicDarkColorScheme(context) + else dynamicLightColorScheme(context) + dark -> darkColorScheme() + else -> lightColorScheme() + } + MaterialTheme(colorScheme = scheme, content = content) +} + +@Composable +private fun FileDropScreen(vm: MainViewModel = viewModel()) { + val address by vm.address.collectAsStateWithLifecycle() + val token by vm.token.collectAsStateWithLifecycle() + val items by vm.items.collectAsStateWithLifecycle() + val message by vm.message.collectAsStateWithLifecycle() + + val snackbarHostState = remember { SnackbarHostState() } + + val picker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments(), + ) { uris -> if (uris.isNotEmpty()) vm.onFilesPicked(uris) } + + LaunchedEffect(message) { + message?.let { + snackbarHostState.showSnackbar(it) + vm.messageShown() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "Drop to PC", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + + OutlinedTextField( + value = address, + onValueChange = vm::setAddress, + label = { Text("PC address") }, + placeholder = { Text("192.168.1.42:8787") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = token, + onValueChange = vm::setToken, + label = { Text("Auth token") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Button( + onClick = { picker.launch(arrayOf("*/*")) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Pick files") + } + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items, key = { it.uri }) { item -> + UploadRow(item, onRetry = { vm.retry(item.uri) }) + } + } + } + } +} + +@Composable +private fun UploadRow(item: UploadItem, onRetry: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + item.name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f, fill = false), + ) + Text( + when (item.status) { + UploadStatus.PENDING -> "Waiting" + UploadStatus.UPLOADING -> "${(item.progress * 100).toInt()}%" + UploadStatus.DONE -> "Done" + UploadStatus.ERROR -> "Failed" + }, + style = MaterialTheme.typography.labelMedium, + color = when (item.status) { + UploadStatus.DONE -> colorScheme.primary + UploadStatus.ERROR -> colorScheme.error + else -> colorScheme.onSurfaceVariant + }, + ) + } + + if (item.status == UploadStatus.UPLOADING || item.status == UploadStatus.PENDING) { + Spacer(Modifier.height(8.dp)) + if (item.progress > 0f) { + LinearProgressIndicator( + progress = { item.progress }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + + if (item.status == UploadStatus.ERROR) { + Spacer(Modifier.height(4.dp)) + item.error?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = colorScheme.error) + } + TextButton(onClick = onRetry) { Text("Retry") } + } + } + } +} diff --git a/app/src/main/java/com/example/filedrop/MainViewModel.kt b/app/src/main/java/com/example/filedrop/MainViewModel.kt new file mode 100644 index 0000000..db605ad --- /dev/null +++ b/app/src/main/java/com/example/filedrop/MainViewModel.kt @@ -0,0 +1,91 @@ +package com.example.filedrop + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +enum class UploadStatus { PENDING, UPLOADING, DONE, ERROR } + +data class UploadItem( + val uri: Uri, + val name: String, + val size: Long, + val progress: Float = 0f, + val status: UploadStatus = UploadStatus.PENDING, + val error: String? = null, +) + +class MainViewModel(app: Application) : AndroidViewModel(app) { + + private val settings = Settings(app) + private val uploader = Uploader(app.contentResolver) + + val address: StateFlow = settings.address + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val token: StateFlow = settings.token + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message + + fun setAddress(value: String) = viewModelScope.launch { settings.setAddress(value) } + fun setToken(value: String) = viewModelScope.launch { settings.setToken(value) } + fun messageShown() { _message.value = null } + + fun onFilesPicked(uris: List) { + val picked = uris.map { uri -> + val meta = uploader.queryMeta(uri) + UploadItem(uri = uri, name = meta.name, size = meta.size) + } + _items.value = picked + uploadAll() + } + + fun retry(uri: Uri) = uploadOne(uri) + + private fun uploadAll() { + _items.value.forEach { uploadOne(it.uri) } + } + + private fun uploadOne(uri: Uri) { + val addr = address.value + if (addr.isBlank()) { + _message.value = "Set the PC address first" + return + } + val item = _items.value.firstOrNull { it.uri == uri } ?: return + update(uri) { it.copy(status = UploadStatus.UPLOADING, progress = 0f, error = null) } + + viewModelScope.launch { + val result = uploader.upload( + address = addr, + token = token.value, + uri = uri, + meta = FileMeta(item.name, item.size), + onProgress = { fraction -> + if (fraction >= 0f) update(uri) { it.copy(progress = fraction) } + }, + ) + result.onSuccess { + update(uri) { it.copy(status = UploadStatus.DONE, progress = 1f) } + _message.value = "Sent ${item.name}" + }.onFailure { e -> + update(uri) { it.copy(status = UploadStatus.ERROR, error = e.message) } + _message.value = "Failed ${item.name}: ${e.message}" + } + } + } + + private fun update(uri: Uri, transform: (UploadItem) -> UploadItem) { + _items.value = _items.value.map { if (it.uri == uri) transform(it) else it } + } +} diff --git a/app/src/main/java/com/example/filedrop/Settings.kt b/app/src/main/java/com/example/filedrop/Settings.kt new file mode 100644 index 0000000..db486a8 --- /dev/null +++ b/app/src/main/java/com/example/filedrop/Settings.kt @@ -0,0 +1,30 @@ +package com.example.filedrop + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +/** Persisted connection settings: the PC address and the shared auth token. */ +class Settings(private val context: Context) { + + private val addressKey = stringPreferencesKey("address") + private val tokenKey = stringPreferencesKey("token") + + val address: Flow = context.dataStore.data.map { it[addressKey].orEmpty() } + val token: Flow = context.dataStore.data.map { it[tokenKey].orEmpty() } + + suspend fun setAddress(value: String) { + context.dataStore.edit { it[addressKey] = value.trim() } + } + + suspend fun setToken(value: String) { + context.dataStore.edit { it[tokenKey] = value.trim() } + } +} diff --git a/app/src/main/java/com/example/filedrop/Uploader.kt b/app/src/main/java/com/example/filedrop/Uploader.kt new file mode 100644 index 0000000..e68202b --- /dev/null +++ b/app/src/main/java/com/example/filedrop/Uploader.kt @@ -0,0 +1,96 @@ +package com.example.filedrop + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okio.BufferedSink +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** Basic file metadata resolved from a content Uri. */ +data class FileMeta(val name: String, val size: Long) + +/** Streams a content Uri to the PC receiver as a multipart upload, one file per request. */ +class Uploader(private val contentResolver: ContentResolver) { + + private val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .writeTimeout(0, TimeUnit.SECONDS) // large files stream indefinitely + .readTimeout(30, TimeUnit.SECONDS) + .build() + + fun queryMeta(uri: Uri): FileMeta { + var name = "file" + var size = -1L + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) + if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx) + if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx) + } + } + return FileMeta(name, size) + } + + /** + * Uploads [uri] to `http://
/upload`. [onProgress] receives a 0f..1f fraction + * (or -1f when the size is unknown). Returns the server's response text on success. + */ + suspend fun upload( + address: String, + token: String, + uri: Uri, + meta: FileMeta, + onProgress: (Float) -> Unit, + ): Result = withContext(Dispatchers.IO) { + runCatching { + val url = "http://${address.trim().trimEnd('/')}/upload" + val mediaType = contentResolver.getType(uri)?.toMediaTypeOrNull() + + val fileBody = object : RequestBody() { + override fun contentType() = mediaType + override fun contentLength() = meta.size + override fun writeTo(sink: BufferedSink) { + val input = contentResolver.openInputStream(uri) + ?: throw IOException("Cannot open $uri") + input.use { + val buffer = ByteArray(64 * 1024) + var uploaded = 0L + while (true) { + val read = it.read(buffer) + if (read == -1) break + sink.write(buffer, 0, read) + uploaded += read + onProgress(if (meta.size > 0) uploaded.toFloat() / meta.size else -1f) + } + } + } + } + + val multipart = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", meta.name, fileBody) + .build() + + val requestBuilder = Request.Builder().url(url).post(multipart) + if (token.isNotBlank()) requestBuilder.header("X-Auth", token.trim()) + + client.newCall(requestBuilder.build()).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw IOException("HTTP ${response.code}: ${body.ifBlank { response.message }}") + } + onProgress(1f) + body.ifBlank { "saved" } + } + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..38a844c --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..93c36af --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + File Drop + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..706ec98 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +