296 lines
11 KiB
Kotlin
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()
|
|
}
|
|
}
|
|
}
|