Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ android {
applicationId "ru.nsu.bobrofon.easysshfs"
minSdkVersion 16
targetSdkVersion 33
versionCode 85
versionName "0.5.11"
versionCode 86
versionName "0.5.12-dev"

externalNativeBuild {
cmake {
Expand Down Expand Up @@ -76,6 +76,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation "androidx.fragment:fragment-ktx:1.6.0"
implementation "androidx.core:core-ktx:1.10.1"
Expand Down
74 changes: 68 additions & 6 deletions app/src/main/java/ru/nsu/bobrofon/easysshfs/EasySSHFSService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,41 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.nsu.bobrofon.easysshfs.mountpointlist.MountPointsList
import ru.nsu.bobrofon.easysshfs.settings.SettingsRepository
import ru.nsu.bobrofon.easysshfs.settings.settingsDataStore
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

private const val TAG = "EasySSHFSService"
private const val CHANNEL_ID = "Channel Mount"
private const val CHANNEL_NAME = "Mount"
private const val NOTIFICATION_ID = 1

class EasySSHFSService : Service() {
class EasySSHFSService : LifecycleService() {

private val handler = Handler(Looper.getMainLooper())
private val internetStateChangeReceiver = InternetStateChangeReceiver(handler)
private val internetStateChangeFilter = IntentFilter(WifiManager.NETWORK_STATE_CHANGED_ACTION)
private val shell: Shell by lazy { EasySSHFSActivity.initNewShell() }

private val notification: Notification by lazy {
NotificationCompat.Builder(applicationContext, CHANNEL_ID).apply {
Expand Down Expand Up @@ -58,8 +71,14 @@ class EasySSHFSService : Service() {

override fun onCreate() {
super.onCreate()

Log.d(TAG, "register NETWORK_STATE_CHANGED receiver")
registerReceiver(internetStateChangeReceiver, internetStateChangeFilter)

Log.d(TAG, "manage periodic ssh servers check")
lifecycleScope.launch {
managePeriodicServersCheck()
}
}

override fun onDestroy() {
Expand All @@ -70,14 +89,54 @@ class EasySSHFSService : Service() {
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)

Log.d(TAG, "onStartCommand")
notificationManager.notify(NOTIFICATION_ID, notification)
startForeground(NOTIFICATION_ID, notification)
return START_STICKY
}

override fun onBind(intent: Intent?): IBinder? {
return null
private suspend fun managePeriodicServersCheck() {
val settingsRepository = SettingsRepository(applicationContext.settingsDataStore)
var periodicJob: Job? = null

settingsRepository.sshServersCheckRequired.collect { isRequired ->
if (isRequired) {
if (periodicJob == null) {
Log.d(TAG, "lunch new periodic ssh servers checker")
periodicJob = lifecycleScope.launch { schedulePeriodicServersChecks() }
}
} else {
if (periodicJob != null) {
Log.d(TAG, "cancel periodic ssh servers checker")
periodicJob?.cancelAndJoin()
periodicJob = null
}
}
}
}

private suspend fun schedulePeriodicServersChecks() {
while (true) {
Log.d(TAG, "waiting for the next remote servers check")
delay(REMOTE_SERVERS_CHECK_PERIOD)

Log.d(TAG, "check if some mountpoints are not automounted")
withContext(Dispatchers.IO) {
try {
val mountpoints = MountPointsList.instance(applicationContext).mountPoints
.filter { it.autoMount }
.filter { !it.checkIfMounted(false).first }
.filter { it.checkIfRemoteIsReachable(REMOTE_SERVER_CONNECTION_TIMEOUT) }

Log.d(TAG, "${mountpoints.size} mountpoints have to be mounted")
mountpoints.forEach { it.mount(shell) }
} catch (e: Exception) {
Log.d(TAG, "periodic remote servers check failed", e)
}
}
}
}

companion object {
Expand All @@ -92,5 +151,8 @@ class EasySSHFSService : Service() {
fun stop(context: Context) {
context.stopService(Intent(context, EasySSHFSService::class.java))
}

private val REMOTE_SERVERS_CHECK_PERIOD = 5.minutes
private val REMOTE_SERVER_CONNECTION_TIMEOUT = 1.seconds
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.lang.ref.WeakReference
import java.util.*
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketTimeoutException
import java.util.LinkedList
import kotlin.time.Duration


class MountPoint(
Expand Down Expand Up @@ -161,6 +165,46 @@ class MountPoint(
}
}

fun checkIfRemoteIsReachable(timeout: Duration): Boolean {
val socket = Socket()
try {
val address = InetSocketAddress(host, port)
socket.connect(address, timeout.inWholeMilliseconds.toInt())

} catch (e: IOException) {
Log.d(TAG, "'$host:$port' connection failed", e)
return false
} catch (e: SocketTimeoutException) {
Log.d(TAG, "'$host:$port' connection timeout", e)
return false
} finally {
try {
socket.close()
} catch (e: Exception) {
Log.w(TAG, "failed to close socket", e)
}
}

return true
}

fun checkIfMounted(foreground: Boolean): Pair<Boolean, String> {
val mountLine = StringBuilder()
mountLine.append("$userName@${hostIp(foreground)}:")

val canonicalLocalPath = File(localPath).canonicalPath
mountLine.append("$remotePath $canonicalLocalPath fuse.sshfs ")

val expectedLine = mountLine.toString()

val procmount = File(MOUNT_FILE)
val result = procmount.useLines { lines ->
lines.any { line -> line.contains(expectedLine) }
}

return Pair(result, expectedLine)
}

companion object {

private const val DEFAULT_OPTIONS = (
Expand All @@ -179,51 +223,34 @@ class MountPoint(

private const val TAG = "MOUNT_POINT"

private class CheckMountTask constructor(
private const val MOUNT_FILE = "/proc/mounts"

private class CheckMountTask(
private val mountPoint: MountPoint,
private val context: WeakReference<Context?> = WeakReference(null)
) :
AsyncTask<Void, Void, Pair<Boolean?, String>>() {

private val mountFile = "/proc/mounts"

constructor(mountPoint: MountPoint, context: Context?) :
this(mountPoint, WeakReference(context))

@Deprecated("Deprecated in Java")
override fun doInBackground(vararg params: Void): Pair<Boolean?, String> {
val fromForeground = context.get() != null
val mountLine = StringBuilder()
mountLine.append("${mountPoint.userName}@${mountPoint.hostIp(fromForeground)}:")

val canonicalLocalPath: String
try {
canonicalLocalPath = File(mountPoint.localPath).canonicalPath
} catch (e: IOException) {
return Pair(
null,
"Can't get canonical path of ${mountPoint.localPath}: ${e.message}"
)
}

mountLine.append("${mountPoint.remotePath} $canonicalLocalPath fuse.sshfs ")

val result: Boolean
val result: Pair<Boolean, String>
try {
val procmount = File(mountFile)
result = procmount.useLines { lines ->
lines.any { line -> line.contains(mountLine.toString()) }
}
result = mountPoint.checkIfMounted(fromForeground)
} catch (e: FileNotFoundException) {
return Pair(null, e.message ?: "")
} catch (e: IOException) {
return Pair(null, e.message ?: "")
}

return if (result) {
Pair(true, "Pattern $mountLine is in $mountFile")
return if (result.first) {
Pair(true, "Pattern ${result.second} is in $MOUNT_FILE")
} else {
Pair(false, "Pattern $mountLine is not in $mountFile")
Pair(false, "Pattern ${result.second} is not in $MOUNT_FILE")
}
}

Expand All @@ -242,7 +269,7 @@ class MountPoint(
}
}

private class MountTask constructor(
private class MountTask(
private val mountPoint: MountPoint,
private val shell: Shell,
private val context: WeakReference<Context?> = WeakReference(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ object Settings {
* Start foreground service to automatically mount/umount mountpoints on network changes.
*/
val autoMountInForegroundService = booleanPreferencesKey("autoMountInForegroundService")

/**
* Check remote ssh services periodically to automatically mount mountpoints.
*/
val checkSshServersPeriodically = booleanPreferencesKey("checkSshServersPeriodically")
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,21 @@ class SettingsFragment(viewModelFactory: SettingsViewModel.Factory) : Preference
}
}

val checkSshServersPeriodicallySwitch =
findPreference<SwitchPreferenceCompat>(Settings.checkSshServersPeriodically.name)?.apply {
setOnPreferenceChangeListener { _, value ->
viewModel.setCheckSshServersPeriodically(value as Boolean)
true
}
}

viewModel.autoMountInForegroundService.observe(this) {
autoMountInForegroundServiceSwitch?.isChecked = it
checkSshServersPeriodicallySwitch?.isEnabled = it
}

viewModel.checkSshServersPeriodically.observe(this) {
checkSshServersPeriodicallySwitch?.isChecked = it
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map

class SettingsRepository(private val settingsDataStore: DataStore<Preferences>) {
Expand All @@ -18,4 +19,20 @@ class SettingsRepository(private val settingsDataStore: DataStore<Preferences>)
settings[Settings.autoMountInForegroundService] = value
}
}

val checkSshServersPeriodically: Flow<Boolean>
get() = settingsDataStore.data.map { settings ->
settings[Settings.checkSshServersPeriodically] ?: false
}

suspend fun setCheckSshServersPeriodically(value: Boolean) {
settingsDataStore.edit { settings ->
settings[Settings.checkSshServersPeriodically] = value
}
}

val sshServersCheckRequired: Flow<Boolean>
get() = autoMountInForegroundService.combine(checkSshServersPeriodically) { autoMount, periodicCheck ->
autoMount && periodicCheck
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,21 @@ class SettingsViewModel(private val repository: SettingsRepository) : ViewModel(
private val _autoMountInForegroundService = MutableLiveData<Boolean>()
val autoMountInForegroundService: LiveData<Boolean> get() = _autoMountInForegroundService

private val _checkSshServersPeriodically = MutableLiveData<Boolean>()
val checkSshServersPeriodically: LiveData<Boolean> get() = _checkSshServersPeriodically

init {
viewModelScope.launch {
repository.autoMountInForegroundService.collect {
_autoMountInForegroundService.value = it
}
}

viewModelScope.launch {
repository.checkSshServersPeriodically.collect {
_checkSshServersPeriodically.value = it
}
}
}

fun setAutoMountInForegroundService(value: Boolean) {
Expand All @@ -22,6 +31,12 @@ class SettingsViewModel(private val repository: SettingsRepository) : ViewModel(
}
}

fun setCheckSshServersPeriodically(value: Boolean) {
viewModelScope.launch {
repository.setCheckSshServersPeriodically(value)
}
}

class Factory(private val repository: SettingsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) {
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,10 @@
Start foreground service to manage network changes all the time</string>
<string name="foreground_service_summary_off">
Manage network changes only when application is in foreground</string>
<string name="periodic_check_title">Check remote servers periodically</string>
<string name="periodic_check_summary_on">
Check remote servers periodically and automount mountpoints if remote server becomes available</string>
<string name="periodic_check_summary_off">
Don\'t try to automount mountpoints if remote server becomes unavailable</string>
<string name="privacy_policy_web_page">Privacy Policy</string>
</resources>
7 changes: 7 additions & 0 deletions app/src/main/res/xml/root_preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
app:summaryOn="@string/foreground_service_summary_on"
app:title="@string/foreground_service_title" />

<SwitchPreferenceCompat
app:key="checkSshServersPeriodically"
app:persistent="false"
app:summaryOff="@string/periodic_check_summary_off"
app:summaryOn="@string/periodic_check_summary_on"
app:title="@string/periodic_check_title" />

</PreferenceCategory>

<Preference
Expand Down