Files
AndPictures/app/src/main/java/com/bm/andpictures/MainActivity.kt
2026-10-18 19:01:00 -07:00

296 lines
11 KiB
Kotlin

// Copyright 2026 Umbra Tools (umbratools.com)
// SPDX-License-Identifier: Apache-2.0
package com.bm.andpictures
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
class MainActivity : AppCompatActivity() {
private lateinit var statusText: TextView
private lateinit var lastSyncText: TextView
private lateinit var etHost: EditText
private lateinit var etPort: EditText
private lateinit var etShare: EditText
private lateinit var etBasePath: EditText
private lateinit var etDomain: EditText
private lateinit var etUser: EditText
private lateinit var etPassword: EditText
private lateinit var etDecadeFormat: EditText
private lateinit var etMonthFormat: EditText
/** True while a manual/one-shot sync is queued or running. */
private var oneShotSyncActive = false
/** True only while the periodic safety-net worker is actually running. */
private var periodicSyncRunning = false
private val mediaPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
bindViews()
// Hamburger menu → About. The framework action bar is already present
// (theme is DarkActionBar), so just dress it with the menu icon.
supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_hamburger)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
populate()
requestNotificationPermissionIfNeeded()
findViewById<Button>(R.id.btnSave).setOnClickListener { save() }
findViewById<Button>(R.id.btnSync).setOnClickListener { syncNow() }
findViewById<Button>(R.id.btnPermission).setOnClickListener { requestMediaPermission() }
findViewById<Button>(R.id.btnTest).setOnClickListener { testConnection() }
findViewById<Button>(R.id.btnPhoneFiles).setOnClickListener { openBrowser(FileBrowserActivity.MODE_PHONE) }
findViewById<Button>(R.id.btnNasFiles).setOnClickListener { openBrowser(FileBrowserActivity.MODE_NAS) }
observeWork()
}
override fun onResume() {
super.onResume()
refreshStatus()
}
/** The hamburger is the action bar's "home" affordance: it opens About. */
override fun onSupportNavigateUp(): Boolean {
startActivity(Intent(this, AboutActivity::class.java))
return true
}
private fun bindViews() {
statusText = findViewById(R.id.statusText)
lastSyncText = findViewById(R.id.lastSyncText)
etHost = findViewById(R.id.etHost)
etPort = findViewById(R.id.etPort)
etShare = findViewById(R.id.etShare)
etBasePath = findViewById(R.id.etBasePath)
etDomain = findViewById(R.id.etDomain)
etUser = findViewById(R.id.etUser)
etPassword = findViewById(R.id.etPassword)
etDecadeFormat = findViewById(R.id.etDecadeFormat)
etMonthFormat = findViewById(R.id.etMonthFormat)
}
private fun populate() {
val p = Prefs(this)
etHost.setText(p.host)
etPort.setText(p.port.toString())
etShare.setText(p.share)
etBasePath.setText(p.basePath)
etDomain.setText(p.domain)
etUser.setText(p.user)
etPassword.setText(p.password)
etDecadeFormat.setText(p.decadeFormat)
etMonthFormat.setText(p.monthFormat)
refreshStatus()
}
private fun save() {
persistSettings()
WorkScheduler.schedulePeriodic(this)
Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show()
}
/** Writes the on-screen fields to prefs without the toast/scheduling side effects. */
private fun persistSettings() {
val p = Prefs(this)
p.host = etHost.text.toString()
p.port = etPort.text.toString().toIntOrNull() ?: Prefs.DEFAULT_PORT
p.share = etShare.text.toString()
p.basePath = etBasePath.text.toString()
p.domain = etDomain.text.toString()
p.user = etUser.text.toString()
p.password = etPassword.text.toString()
p.decadeFormat = etDecadeFormat.text.toString()
p.monthFormat = etMonthFormat.text.toString()
}
private fun syncNow() {
if (!PhotoStore.hasPermission(this)) {
requestMediaPermission()
Toast.makeText(this, "Grant photo access first", Toast.LENGTH_SHORT).show()
return
}
if (!ensureCredentials()) return
save()
WorkScheduler.requestSync(this, replace = true)
statusText.text = "Syncing…"
}
/**
* The photo share is protected, so nothing is sent to the NAS until the
* username and password are filled in.
*/
private fun ensureCredentials(): Boolean {
val userMissing = etUser.text.toString().isBlank()
if (userMissing || etPassword.text.toString().isBlank()) {
Toast.makeText(
this,
"Enter the photo-share username and password first",
Toast.LENGTH_LONG,
).show()
if (userMissing) etUser.requestFocus() else etPassword.requestFocus()
return false
}
return true
}
/**
* Connects with the current settings and prints what is actually on the NAS
* under the base directory, so the real decade/year/month naming can be
* confirmed without guessing.
*/
private fun testConnection() {
if (!ensureCredentials()) return
save()
statusText.text = "Testing connection…"
lifecycleScope.launch {
statusText.text = withContext(Dispatchers.IO) { probe() }
}
}
private fun probe(): String {
val p = Prefs(this)
if (!p.isConfigured) return "Set the photo-share username and password first"
val uploader = SmbUploader(
host = p.host,
port = p.port,
shareName = p.share,
domain = p.domain,
user = p.user,
password = p.password,
)
return try {
uploader.connect()
val base = p.basePath
val baseLabel = base.ifBlank { "<share root>" }
val sb = StringBuilder("Connected to //${p.host}/${p.share}\n")
val decades = uploader.listFolders(base)
sb.append("$baseLabel: ")
sb.append(if (decades.isEmpty()) "(no subfolders yet)" else decades.joinToString(", "))
for (decade in decades.takeLast(2)) {
val decadePath = joinPath(base, decade)
val years = uploader.listFolders(decadePath)
sb.append("\n $decade: ")
sb.append(if (years.isEmpty()) "(empty)" else years.joinToString(", "))
for (year in years.takeLast(2)) {
val yearPath = joinPath(decadePath, year)
val months = uploader.listFolders(yearPath)
sb.append("\n $year: ")
sb.append(if (months.isEmpty()) "(empty)" else months.joinToString(", "))
}
}
sb.toString().also {
Log.i(SyncWorker.TAG, "test connection -> ${it.replace('\n', ' ')}")
}
} catch (t: Throwable) {
Log.w(SyncWorker.TAG, "test connection failed: ${t.message}", t)
"Connection failed: ${t.message ?: t.javaClass.simpleName}"
} finally {
runCatching { uploader.close() }
}
}
private fun joinPath(parent: String, child: String): String =
if (parent.isBlank()) child else "$parent\\$child"
private fun refreshStatus() {
val p = Prefs(this)
lastSyncText.text = if (p.lastSyncMillis == 0L) {
"Last sync cursor: never"
} else {
"Last sync cursor: " +
DateFormat.getDateTimeInstance().format(Date(p.lastSyncMillis))
}
statusText.text = p.lastStatus.ifBlank { getString(R.string.status_idle) }
}
/**
* Repaints the status/cursor lines from whatever sync is in flight right now.
* When nothing is running this delegates to [refreshStatus] so the cursor line
* also refreshes while the app stays in the foreground (otherwise a completed
* sync would leave a stale "Last sync cursor" until the next onResume).
*/
private fun renderStatus() {
if (oneShotSyncActive || periodicSyncRunning) {
statusText.text = "Syncing…"
} else {
refreshStatus()
}
}
private fun requestMediaPermission() {
if (!PhotoStore.hasPermission(this)) {
mediaPermissionLauncher.launch(PhotoStore.mediaPermission)
}
}
/** Opens the read-only file browser for the phone library or the NAS share. */
private fun openBrowser(mode: String) {
if (mode == FileBrowserActivity.MODE_NAS) {
// The browser reads prefs directly, so flush the on-screen values first.
persistSettings()
}
startActivity(
Intent(this, FileBrowserActivity::class.java)
.putExtra(FileBrowserActivity.EXTRA_MODE, mode),
)
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
private fun observeWork() {
val wm = WorkManager.getInstance(this)
wm.getWorkInfosForUniqueWorkLiveData(Prefs.WORK_ONESHOT).observe(this) { infos ->
// ENQUEUED means "queued, waiting for a network/slot", which is still
// an active sync from the user's point of view.
val state = infos.lastOrNull()?.state
oneShotSyncActive =
state == WorkInfo.State.ENQUEUED || state == WorkInfo.State.RUNNING
renderStatus()
}
wm.getWorkInfosForUniqueWorkLiveData(Prefs.WORK_PERIODIC).observe(this) { infos ->
// The periodic safety-net job sits in ENQUEUED while it waits for its
// next window, so only a RUNNING job counts as a live sync.
periodicSyncRunning = infos.lastOrNull()?.state == WorkInfo.State.RUNNING
renderStatus()
}
}
}