internaluploader: server + docs
One-way upload server: authentik SSO pairing (QR + app-initiated deep link), hashed device tokens, streamed sha256-verified uploads into the metube directory as <sha256>.<ext>. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
55
android/app/src/main/AndroidManifest.xml
Normal file
55
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.InternalUploader">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Share-sheet target: anything shareable can be queued. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<!-- In-app SSO pairing lands back here: internaluploader://pair?c=… -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="internaluploader" android:host="pair" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- WorkManager's foreground service must declare the same type the
|
||||
upload worker requests, or startForeground throws on 34+. -->
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
</application>
|
||||
</manifest>
|
||||
24
android/app/src/main/java/eu/itaz/internaluploader/App.kt
Normal file
24
android/app/src/main/java/eu/itaz/internaluploader/App.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
package eu.itaz.internaluploader
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
|
||||
class App : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_UPLOADS,
|
||||
getString(R.string.channel_uploads),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_UPLOADS = "uploads"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package eu.itaz.internaluploader
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import eu.itaz.internaluploader.data.AppDb
|
||||
import eu.itaz.internaluploader.data.Status
|
||||
import eu.itaz.internaluploader.databinding.ActivityMainBinding
|
||||
import eu.itaz.internaluploader.pairing.PairApi
|
||||
import eu.itaz.internaluploader.pairing.PairPayload
|
||||
import eu.itaz.internaluploader.pairing.PairingStore
|
||||
import eu.itaz.internaluploader.ui.QueueAdapter
|
||||
import eu.itaz.internaluploader.upload.Enqueuer
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var store: PairingStore
|
||||
private val adapter = QueueAdapter()
|
||||
|
||||
private val scanQr = registerForActivityResult(ScanContract()) { result ->
|
||||
val payload = result.contents?.let(PairPayload.Companion::fromQr)
|
||||
if (payload == null) {
|
||||
if (result.contents != null) toast(R.string.pair_qr_invalid)
|
||||
} else {
|
||||
completePairing(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private val pickFiles =
|
||||
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||
uris.forEach(::enqueueUri)
|
||||
}
|
||||
|
||||
private val askNotifications =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.toolbar)
|
||||
store = PairingStore(this)
|
||||
|
||||
binding.queue.layoutManager = LinearLayoutManager(this)
|
||||
binding.queue.adapter = adapter
|
||||
|
||||
binding.scanButton.setOnClickListener { startScan() }
|
||||
binding.ssoButton.setOnClickListener { startAppSso() }
|
||||
binding.addButton.setOnClickListener { pickFiles.launch(arrayOf("*/*")) }
|
||||
binding.retryButton.setOnClickListener {
|
||||
lifecycleScope.launch { Enqueuer.retryFailed(this@MainActivity) }
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
AppDb.get(this@MainActivity).uploads().observeAll().collectLatest { items ->
|
||||
adapter.submitList(items)
|
||||
binding.empty.visibility =
|
||||
if (items.isEmpty() && store.current() != null) View.VISIBLE else View.GONE
|
||||
binding.retryButton.visibility =
|
||||
if (items.any { it.status == Status.FAILED }) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
render()
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
val code = intent.data?.getQueryParameter("c")
|
||||
if (code != null) completePairing(PairPayload.fromDeepLinkCode(code))
|
||||
}
|
||||
Intent.ACTION_SEND -> {
|
||||
sharedUri(intent)?.let(::enqueueUri)
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
sharedUris(intent).forEach(::enqueueUri)
|
||||
}
|
||||
}
|
||||
// A share intent is consumed once; re-delivery on rotation would
|
||||
// double-queue (dedupe by hash makes that harmless server-side, but
|
||||
// the queue UI would still show it twice).
|
||||
intent.action = null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun sharedUri(intent: Intent): Uri? =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun sharedUris(intent: Intent): List<Uri> =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) ?: emptyList()
|
||||
} else {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) ?: emptyList()
|
||||
}
|
||||
|
||||
private fun enqueueUri(uri: Uri) {
|
||||
if (store.current() == null) {
|
||||
toast(R.string.pair_first)
|
||||
return
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
Enqueuer.enqueue(this@MainActivity, uri)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
getString(R.string.enqueue_failed, e.message ?: ""),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startScan() {
|
||||
scanQr.launch(
|
||||
ScanOptions()
|
||||
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
.setPrompt(getString(R.string.pair_scan_prompt))
|
||||
.setBeepEnabled(false)
|
||||
.setOrientationLocked(false),
|
||||
)
|
||||
}
|
||||
|
||||
private fun startAppSso() {
|
||||
CustomTabsIntent.Builder().build()
|
||||
.launchUrl(this, "${BuildConfig.DEFAULT_SERVER_URL}/pair/app".toUri())
|
||||
}
|
||||
|
||||
private fun completePairing(payload: PairPayload) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
store.save(PairApi.pair(payload))
|
||||
toast(R.string.pair_success)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
getString(R.string.pair_failed, e.message ?: ""),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
render()
|
||||
}
|
||||
}
|
||||
|
||||
private fun render() {
|
||||
val paired = store.current()
|
||||
binding.pairGroup.visibility = if (paired == null) View.VISIBLE else View.GONE
|
||||
binding.uploadGroup.visibility = if (paired == null) View.GONE else View.VISIBLE
|
||||
binding.pairedAs.text =
|
||||
if (paired == null) "" else getString(R.string.paired_as, paired.name)
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.main_menu, menu)
|
||||
val paired = store.current() != null
|
||||
menu.findItem(R.id.action_unpair).isVisible = paired
|
||||
menu.findItem(R.id.action_clear_finished).isVisible = paired
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean =
|
||||
when (item.itemId) {
|
||||
R.id.action_unpair -> {
|
||||
store.clear()
|
||||
render()
|
||||
true
|
||||
}
|
||||
R.id.action_clear_finished -> {
|
||||
lifecycleScope.launch {
|
||||
AppDb.get(this@MainActivity).uploads().deleteByStatus(Status.DONE)
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun toast(resId: Int) {
|
||||
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package eu.itaz.internaluploader.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
object Status {
|
||||
const val PENDING = "pending"
|
||||
const val UPLOADING = "uploading"
|
||||
const val DONE = "done"
|
||||
const val FAILED = "failed"
|
||||
}
|
||||
|
||||
@Entity(tableName = "uploads")
|
||||
data class UploadItem(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
/** What the user shared — display only; never sent to the server. */
|
||||
val originalName: String,
|
||||
/** `<sha256>.<ext>` — the only name the server ever sees. */
|
||||
val remoteName: String,
|
||||
/** Private copy the worker uploads from; survives source URI revocation. */
|
||||
val cachePath: String,
|
||||
val size: Long,
|
||||
val status: String = Status.PENDING,
|
||||
val progress: Int = 0,
|
||||
val error: String? = null,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface UploadDao {
|
||||
@Insert
|
||||
suspend fun insert(item: UploadItem): Long
|
||||
|
||||
@Query("SELECT * FROM uploads ORDER BY createdAt DESC")
|
||||
fun observeAll(): Flow<List<UploadItem>>
|
||||
|
||||
@Query("SELECT * FROM uploads WHERE id = :id")
|
||||
suspend fun byId(id: Long): UploadItem?
|
||||
|
||||
@Query("SELECT * FROM uploads WHERE status = :status")
|
||||
suspend fun byStatus(status: String): List<UploadItem>
|
||||
|
||||
@Query("UPDATE uploads SET status = :status, progress = :progress, error = :error WHERE id = :id")
|
||||
suspend fun setStatus(id: Long, status: String, progress: Int = 0, error: String? = null)
|
||||
|
||||
@Query("DELETE FROM uploads WHERE status = :status")
|
||||
suspend fun deleteByStatus(status: String)
|
||||
}
|
||||
|
||||
@Database(entities = [UploadItem::class], version = 1, exportSchema = false)
|
||||
abstract class AppDb : RoomDatabase() {
|
||||
abstract fun uploads(): UploadDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: AppDb? = null
|
||||
|
||||
fun get(context: Context): AppDb =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDb::class.java,
|
||||
"internaluploader.db",
|
||||
).build().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package eu.itaz.internaluploader.pairing
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import eu.itaz.internaluploader.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
|
||||
data class PairedState(val serverUrl: String, val token: String, val name: String)
|
||||
|
||||
/**
|
||||
* Device credential store. The server URL lives here too — it arrives inside
|
||||
* the pairing QR (or is the baked-in default for the in-app SSO path) and is
|
||||
* deliberately never surfaced anywhere in the UI.
|
||||
*/
|
||||
class PairingStore(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences
|
||||
|
||||
init {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"pairing",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
fun current(): PairedState? {
|
||||
val url = prefs.getString(KEY_URL, null) ?: return null
|
||||
val token = prefs.getString(KEY_TOKEN, null) ?: return null
|
||||
val name = prefs.getString(KEY_NAME, null) ?: return null
|
||||
return PairedState(url, token, name)
|
||||
}
|
||||
|
||||
fun save(state: PairedState) {
|
||||
prefs.edit()
|
||||
.putString(KEY_URL, state.serverUrl)
|
||||
.putString(KEY_TOKEN, state.token)
|
||||
.putString(KEY_NAME, state.name)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_URL = "serverUrl"
|
||||
const val KEY_TOKEN = "token"
|
||||
const val KEY_NAME = "name"
|
||||
}
|
||||
}
|
||||
|
||||
/** QR / deep-link payload: {"v":1,"u":"https://…","c":"<one-time code>"}. */
|
||||
data class PairPayload(val serverUrl: String, val code: String) {
|
||||
companion object {
|
||||
fun fromQr(text: String): PairPayload? = try {
|
||||
val json = JSONObject(text)
|
||||
if (json.optInt("v") != 1) null
|
||||
else {
|
||||
val url = json.optString("u")
|
||||
val code = json.optString("c")
|
||||
if (url.startsWith("https://") && code.isNotEmpty()) {
|
||||
PairPayload(url.trimEnd('/'), code)
|
||||
} else null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Deep link carries only the code; the origin is the baked-in default. */
|
||||
fun fromDeepLinkCode(code: String): PairPayload =
|
||||
PairPayload(BuildConfig.DEFAULT_SERVER_URL, code)
|
||||
}
|
||||
}
|
||||
|
||||
class PairingException(message: String) : Exception(message)
|
||||
|
||||
object PairApi {
|
||||
|
||||
private val client = OkHttpClient()
|
||||
private val jsonType = "application/json".toMediaType()
|
||||
|
||||
/** Exchange a one-time pairing code for the long-lived device token. */
|
||||
suspend fun pair(payload: PairPayload): PairedState = withContext(Dispatchers.IO) {
|
||||
val body = JSONObject().put("code", payload.code).toString().toRequestBody(jsonType)
|
||||
val request = Request.Builder()
|
||||
.url("${payload.serverUrl}/api/pair")
|
||||
.post(body)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
when {
|
||||
response.isSuccessful -> {
|
||||
val json = JSONObject(response.body!!.string())
|
||||
PairedState(
|
||||
serverUrl = payload.serverUrl,
|
||||
token = json.getString("token"),
|
||||
name = json.getString("name"),
|
||||
)
|
||||
}
|
||||
response.code == 410 || response.code == 400 ->
|
||||
throw PairingException("code expired or already used")
|
||||
else -> throw PairingException("pairing failed (HTTP ${response.code})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package eu.itaz.internaluploader.ui
|
||||
|
||||
import android.text.format.Formatter
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.itaz.internaluploader.R
|
||||
import eu.itaz.internaluploader.data.Status
|
||||
import eu.itaz.internaluploader.data.UploadItem
|
||||
import eu.itaz.internaluploader.databinding.ItemUploadBinding
|
||||
|
||||
class QueueAdapter : ListAdapter<UploadItem, QueueAdapter.Holder>(Diff) {
|
||||
|
||||
class Holder(val binding: ItemUploadBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder =
|
||||
Holder(ItemUploadBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val item = getItem(position)
|
||||
val context = holder.binding.root.context
|
||||
holder.binding.name.text = item.originalName
|
||||
holder.binding.size.text = Formatter.formatShortFileSize(context, item.size)
|
||||
|
||||
holder.binding.progress.visibility =
|
||||
if (item.status == Status.UPLOADING) View.VISIBLE else View.GONE
|
||||
holder.binding.progress.isIndeterminate = item.progress == 0
|
||||
holder.binding.progress.progress = item.progress
|
||||
|
||||
holder.binding.status.text = when (item.status) {
|
||||
Status.PENDING -> context.getString(R.string.status_pending)
|
||||
Status.UPLOADING -> context.getString(R.string.status_uploading, item.progress)
|
||||
Status.DONE -> context.getString(R.string.status_done)
|
||||
Status.FAILED -> context.getString(
|
||||
R.string.status_failed,
|
||||
item.error ?: context.getString(R.string.status_failed_unknown),
|
||||
)
|
||||
else -> item.status
|
||||
}
|
||||
holder.binding.check.visibility =
|
||||
if (item.status == Status.DONE) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private object Diff : DiffUtil.ItemCallback<UploadItem>() {
|
||||
override fun areItemsTheSame(a: UploadItem, b: UploadItem) = a.id == b.id
|
||||
override fun areContentsTheSame(a: UploadItem, b: UploadItem) = a == b
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package eu.itaz.internaluploader.upload
|
||||
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Streams a file as the request body, reporting bytes-sent roughly once per
|
||||
* second so progress writes don't outnumber the payload reads.
|
||||
*/
|
||||
class CountingFileRequestBody(
|
||||
private val file: File,
|
||||
private val onProgress: (bytesSent: Long) -> Unit,
|
||||
) : RequestBody() {
|
||||
|
||||
override fun contentType(): MediaType = "application/octet-stream".toMediaType()
|
||||
|
||||
override fun contentLength(): Long = file.length()
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
var sent = 0L
|
||||
var lastReport = 0L
|
||||
file.source().use { source ->
|
||||
while (true) {
|
||||
val read = source.read(sink.buffer, SEGMENT)
|
||||
if (read < 0) break
|
||||
sent += read
|
||||
sink.emitCompleteSegments()
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastReport >= 1000) {
|
||||
lastReport = now
|
||||
onProgress(sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
onProgress(sent)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SEGMENT = 64L * 1024
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package eu.itaz.internaluploader.upload
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import eu.itaz.internaluploader.data.AppDb
|
||||
import eu.itaz.internaluploader.data.Status
|
||||
import eu.itaz.internaluploader.data.UploadItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object Enqueuer {
|
||||
|
||||
/**
|
||||
* Copy the shared content into app-private storage while hashing it, so a
|
||||
* retry never depends on the source URI still being readable, then hand
|
||||
* the file to WorkManager.
|
||||
*/
|
||||
suspend fun enqueue(context: Context, uri: Uri): UploadItem = withContext(Dispatchers.IO) {
|
||||
val originalName = displayNameOf(context, uri) ?: "unnamed"
|
||||
|
||||
val queueDir = File(context.filesDir, "queue").apply { mkdirs() }
|
||||
val cacheFile = File(queueDir, UUID.randomUUID().toString())
|
||||
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
var size = 0L
|
||||
context.contentResolver.openInputStream(uri).use { input ->
|
||||
requireNotNull(input) { "source stream is gone" }
|
||||
cacheFile.outputStream().use { output ->
|
||||
val buffer = ByteArray(1 shl 16)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
digest.update(buffer, 0, read)
|
||||
output.write(buffer, 0, read)
|
||||
size += read
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val hash = digest.digest().joinToString("") { "%02x".format(it) }
|
||||
val ext = originalName.substringAfterLast('.', "").lowercase()
|
||||
.takeIf { it.isNotEmpty() && it.length <= 16 && it.all { c -> c.isLetterOrDigit() } }
|
||||
val remoteName = if (ext != null) "$hash.$ext" else hash
|
||||
|
||||
val db = AppDb.get(context)
|
||||
val item = UploadItem(
|
||||
originalName = originalName,
|
||||
remoteName = remoteName,
|
||||
cachePath = cacheFile.absolutePath,
|
||||
size = size,
|
||||
)
|
||||
val id = db.uploads().insert(item)
|
||||
schedule(context, id)
|
||||
item.copy(id = id)
|
||||
}
|
||||
|
||||
/** (Re-)schedule the worker for one queue item. */
|
||||
fun schedule(context: Context, itemId: Long) {
|
||||
val request = OneTimeWorkRequestBuilder<UploadWorker>()
|
||||
.setInputData(workDataOf(UploadWorker.KEY_ITEM_ID to itemId))
|
||||
.setConstraints(
|
||||
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
|
||||
)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork("upload-$itemId", ExistingWorkPolicy.KEEP, request)
|
||||
}
|
||||
|
||||
suspend fun retryFailed(context: Context) {
|
||||
val db = AppDb.get(context)
|
||||
for (item in db.uploads().byStatus(Status.FAILED)) {
|
||||
if (File(item.cachePath).exists()) {
|
||||
db.uploads().setStatus(item.id, Status.PENDING)
|
||||
// REPLACE: the previous unique work chain for this id has already
|
||||
// terminated in failure; KEEP would ignore the new request.
|
||||
val request = OneTimeWorkRequestBuilder<UploadWorker>()
|
||||
.setInputData(workDataOf(UploadWorker.KEY_ITEM_ID to item.id))
|
||||
.setConstraints(
|
||||
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
|
||||
)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork("upload-${item.id}", ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayNameOf(context: Context, uri: Uri): String? {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (index >= 0 && cursor.moveToFirst()) {
|
||||
return cursor.getString(index)
|
||||
}
|
||||
}
|
||||
return uri.lastPathSegment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package eu.itaz.internaluploader.upload
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import eu.itaz.internaluploader.App
|
||||
import eu.itaz.internaluploader.R
|
||||
import eu.itaz.internaluploader.data.AppDb
|
||||
import eu.itaz.internaluploader.data.Status
|
||||
import eu.itaz.internaluploader.data.UploadItem
|
||||
import eu.itaz.internaluploader.pairing.PairingStore
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UploadWorker(context: Context, params: WorkerParameters) :
|
||||
CoroutineWorker(context, params) {
|
||||
|
||||
private val db = AppDb.get(context)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val itemId = inputData.getLong(KEY_ITEM_ID, -1)
|
||||
val item = db.uploads().byId(itemId) ?: return Result.failure()
|
||||
if (item.status == Status.DONE) return Result.success()
|
||||
|
||||
val paired = PairingStore(applicationContext).current()
|
||||
?: return permanentFailure(item, applicationContext.getString(R.string.error_not_paired))
|
||||
|
||||
val file = File(item.cachePath)
|
||||
if (!file.exists()) {
|
||||
return permanentFailure(item, applicationContext.getString(R.string.error_cache_gone))
|
||||
}
|
||||
|
||||
// Long uploads outlive the default 10-minute background budget; run as
|
||||
// a dataSync foreground job with visible progress. Starting foreground
|
||||
// can be refused (e.g. app freshly in background on 12+) — the upload
|
||||
// itself still proceeds, just without a notification.
|
||||
runCatching { setForeground(foregroundInfo(item, 0)) }
|
||||
|
||||
db.uploads().setStatus(item.id, Status.UPLOADING)
|
||||
|
||||
return try {
|
||||
when (val outcome = upload(paired.serverUrl, paired.token, item, file)) {
|
||||
UploadOutcome.DONE -> {
|
||||
db.uploads().setStatus(item.id, Status.DONE, progress = 100)
|
||||
file.delete()
|
||||
Result.success()
|
||||
}
|
||||
UploadOutcome.UNAUTHORIZED ->
|
||||
permanentFailure(item, applicationContext.getString(R.string.error_unauthorized))
|
||||
UploadOutcome.REJECTED ->
|
||||
permanentFailure(item, applicationContext.getString(R.string.error_rejected))
|
||||
is UploadOutcome.TRANSIENT -> {
|
||||
db.uploads().setStatus(item.id, Status.PENDING, error = outcome.message)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
db.uploads().setStatus(item.id, Status.PENDING, error = e.message)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface UploadOutcome {
|
||||
data object DONE : UploadOutcome
|
||||
data object UNAUTHORIZED : UploadOutcome
|
||||
data object REJECTED : UploadOutcome
|
||||
data class TRANSIENT(val message: String) : UploadOutcome
|
||||
}
|
||||
|
||||
private suspend fun upload(
|
||||
serverUrl: String,
|
||||
token: String,
|
||||
item: UploadItem,
|
||||
file: File,
|
||||
): UploadOutcome {
|
||||
// A retried upload may already have landed: ask before sending bytes.
|
||||
val head = Request.Builder()
|
||||
.url("$serverUrl/api/object/${item.remoteName}")
|
||||
.head()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
client.newCall(head).execute().use { response ->
|
||||
when (response.code) {
|
||||
204 -> return UploadOutcome.DONE
|
||||
401 -> return UploadOutcome.UNAUTHORIZED
|
||||
404 -> Unit // expected: not there yet
|
||||
else -> return UploadOutcome.TRANSIENT("HEAD ${response.code}")
|
||||
}
|
||||
}
|
||||
|
||||
val body = CountingFileRequestBody(file) { sent ->
|
||||
val percent = if (item.size > 0) ((sent * 100) / item.size).toInt() else 0
|
||||
setProgressAsync(workDataOf(KEY_PROGRESS to percent))
|
||||
runCatching {
|
||||
setForegroundAsync(foregroundInfo(item, percent))
|
||||
}
|
||||
// Room write per callback would thrash; CountingFileRequestBody
|
||||
// already throttles callbacks to ~1/s.
|
||||
kotlinx.coroutines.runBlocking {
|
||||
db.uploads().setStatus(item.id, Status.UPLOADING, progress = percent)
|
||||
}
|
||||
}
|
||||
val put = Request.Builder()
|
||||
.url("$serverUrl/api/upload?name=${item.remoteName}")
|
||||
.put(body)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
client.newCall(put).execute().use { response ->
|
||||
return when (response.code) {
|
||||
200, 201 -> UploadOutcome.DONE
|
||||
401 -> UploadOutcome.UNAUTHORIZED
|
||||
409, 413, 400 -> UploadOutcome.REJECTED
|
||||
else -> UploadOutcome.TRANSIENT("PUT ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun permanentFailure(item: UploadItem, message: String): Result {
|
||||
db.uploads().setStatus(item.id, Status.FAILED, error = message)
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
private fun foregroundInfo(item: UploadItem, percent: Int): ForegroundInfo {
|
||||
val notification: Notification =
|
||||
NotificationCompat.Builder(applicationContext, App.CHANNEL_UPLOADS)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(applicationContext.getString(R.string.notification_uploading))
|
||||
.setContentText(item.originalName)
|
||||
.setProgress(100, percent, percent == 0)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
return if (Build.VERSION.SDK_INT >= 29) {
|
||||
ForegroundInfo(
|
||||
item.id.toInt(),
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
} else {
|
||||
ForegroundInfo(item.id.toInt(), notification)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_ITEM_ID = "itemId"
|
||||
const val KEY_PROGRESS = "progress"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(2, TimeUnit.MINUTES)
|
||||
// A multi-GB PUT writes for as long as it writes.
|
||||
.writeTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
9
android/app/src/main/res/drawable/ic_check.xml
Normal file
9
android/app/src/main/res/drawable/ic_check.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
|
||||
</vector>
|
||||
15
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
15
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Up arrow into a tray: one-way upload. -->
|
||||
<group android:translateX="30" android:translateY="28">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M24,0 L40,18 L31,18 L31,34 L17,34 L17,18 L8,18 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M0,40 L8,40 L8,44 L40,44 L40,40 L48,40 L48,52 L0,52 Z" />
|
||||
</group>
|
||||
</vector>
|
||||
9
android/app/src/main/res/drawable/ic_notification.xml
Normal file
9
android/app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M9,16h6v-6h4l-7,-7 -7,7h4zM5,18h14v2L5,20z" />
|
||||
</vector>
|
||||
122
android/app/src/main/res/layout/activity_main.xml
Normal file
122
android/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:title="@string/app_name" />
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<!-- Unpaired: the two ways in. -->
|
||||
<LinearLayout
|
||||
android:id="@+id/pairGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal"
|
||||
android:padding="32dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="96dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_launcher_foreground" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/pair_explainer"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/scanButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
android:text="@string/pair_scan_button" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/ssoButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/pair_sso_button" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Paired: the queue. -->
|
||||
<LinearLayout
|
||||
android:id="@+id/uploadGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pairedAs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="8dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/addButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/add_files" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/retryButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/retry_failed"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/empty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="32dp"
|
||||
android:text="@string/queue_empty"
|
||||
android:textAlignment="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/queue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="16dp" />
|
||||
</LinearLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
60
android/app/src/main/res/layout/item_upload.xml
Normal file
60
android/app/src/main/res/layout/item_upload.xml
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="10dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="middle"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
app:layout_constraintEnd_toStartOf="@id/check"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/check"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_check"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@id/name"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/name"
|
||||
app:tint="?attr/colorPrimary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
app:layout_constraintEnd_toStartOf="@id/size"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/size"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/name" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/status" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
12
android/app/src/main/res/menu/main_menu.xml
Normal file
12
android/app/src/main/res/menu/main_menu.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item
|
||||
android:id="@+id/action_clear_finished"
|
||||
android:title="@string/action_clear_finished"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/action_unpair"
|
||||
android:title="@string/action_unpair"
|
||||
app:showAsAction="never" />
|
||||
</menu>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
android/app/src/main/res/values/colors.xml
Normal file
5
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<color name="brand">#2E5AAC</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="ic_launcher_background">#2E5AAC</color>
|
||||
</resources>
|
||||
35
android/app/src/main/res/values/strings.xml
Normal file
35
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<resources>
|
||||
<string name="app_name">InternalUploader</string>
|
||||
<string name="channel_uploads">Uploads</string>
|
||||
|
||||
<string name="pair_explainer">Pair this device to start uploading. Sign in on any browser and scan the code, or sign in right here.</string>
|
||||
<string name="pair_scan_button">Scan QR code</string>
|
||||
<string name="pair_sso_button">Sign in</string>
|
||||
<string name="pair_scan_prompt">Scan the pairing code from your browser</string>
|
||||
<string name="pair_qr_invalid">Not a pairing code</string>
|
||||
<string name="pair_success">Paired</string>
|
||||
<string name="pair_failed">Pairing failed: %1$s</string>
|
||||
<string name="pair_first">Pair this device first</string>
|
||||
<string name="paired_as">Paired as %1$s</string>
|
||||
|
||||
<string name="add_files">Add files</string>
|
||||
<string name="retry_failed">Retry failed</string>
|
||||
<string name="queue_empty">Nothing queued. Share files to InternalUploader or add them here.</string>
|
||||
<string name="enqueue_failed">Could not queue file: %1$s</string>
|
||||
|
||||
<string name="status_pending">Waiting…</string>
|
||||
<string name="status_uploading">Uploading… %1$d%%</string>
|
||||
<string name="status_done">Uploaded</string>
|
||||
<string name="status_failed">Failed: %1$s</string>
|
||||
<string name="status_failed_unknown">unknown error</string>
|
||||
|
||||
<string name="notification_uploading">Uploading</string>
|
||||
|
||||
<string name="error_not_paired">device is not paired</string>
|
||||
<string name="error_cache_gone">queued copy is gone</string>
|
||||
<string name="error_unauthorized">rejected — unpair and pair again</string>
|
||||
<string name="error_rejected">rejected by server</string>
|
||||
|
||||
<string name="action_unpair">Unpair</string>
|
||||
<string name="action_clear_finished">Clear finished</string>
|
||||
</resources>
|
||||
6
android/app/src/main/res/values/themes.xml
Normal file
6
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<style name="Theme.InternalUploader" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/brand</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user