Compare commits
8 Commits
ffdab4563d
...
08caf08fad
| Author | SHA1 | Date | |
|---|---|---|---|
| 08caf08fad | |||
| 7dd4a43c3d | |||
| 671f1f1c2a | |||
| 467ed64426 | |||
| da8b970078 | |||
| c58bcdc35b | |||
| 9c38da76d2 | |||
| 184edc0b67 |
@@ -150,7 +150,7 @@ class TaskPipelineForegroundService : Service() {
|
|||||||
NotificationCompat.Builder(this, WallencExternalLaunch.ForegroundTaskPipelineNotification.CHANNEL_ID)
|
NotificationCompat.Builder(this, WallencExternalLaunch.ForegroundTaskPipelineNotification.CHANNEL_ID)
|
||||||
.setContentTitle(getString(R.string.task_notification_title))
|
.setContentTitle(getString(R.string.task_notification_title))
|
||||||
.setContentText(getString(R.string.task_notification_preparing))
|
.setContentText(getString(R.string.task_notification_preparing))
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
.setSmallIcon(R.drawable.ic_stat_wallenc)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setContentIntent(openTaskPipelinePendingIntent())
|
.setContentIntent(openTaskPipelinePendingIntent())
|
||||||
@@ -187,7 +187,7 @@ class TaskPipelineForegroundService : Service() {
|
|||||||
)
|
)
|
||||||
.setContentTitle(getString(R.string.task_notification_title))
|
.setContentTitle(getString(R.string.task_notification_title))
|
||||||
.setContentText(collapsedSubtext)
|
.setContentText(collapsedSubtext)
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
.setSmallIcon(R.drawable.ic_stat_wallenc)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setContentIntent(openTaskPipelinePendingIntent())
|
.setContentIntent(openTaskPipelinePendingIntent())
|
||||||
|
|||||||
@@ -1,170 +1,25 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<path android:pathData="M0,0h108v108h-108z">
|
||||||
android:fillColor="#3DDC84"
|
<aapt:attr name="android:fillColor">
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
<gradient
|
||||||
<path
|
android:endX="92"
|
||||||
android:fillColor="#00000000"
|
android:endY="100"
|
||||||
android:pathData="M9,0L9,108"
|
android:startX="16"
|
||||||
android:strokeWidth="0.8"
|
android:startY="8"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:type="linear">
|
||||||
<path
|
<item
|
||||||
android:fillColor="#00000000"
|
android:color="@color/launcher_background"
|
||||||
android:pathData="M19,0L19,108"
|
android:offset="0" />
|
||||||
android:strokeWidth="0.8"
|
<item
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:color="@color/launcher_background_dark"
|
||||||
<path
|
android:offset="1" />
|
||||||
android:fillColor="#00000000"
|
</gradient>
|
||||||
android:pathData="M29,0L29,108"
|
</aapt:attr>
|
||||||
android:strokeWidth="0.8"
|
</path>
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,0L39,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,0L49,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,0L59,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,0L69,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,0L79,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M89,0L89,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M99,0L99,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,9L108,9"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,19L108,19"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,29L108,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
<!-- Shackle -->
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:endX="85.84757"
|
|
||||||
android:endY="92.4963"
|
|
||||||
android:startX="42.9492"
|
|
||||||
android:startY="49.59793"
|
|
||||||
android:type="linear">
|
|
||||||
<item
|
|
||||||
android:color="#44000000"
|
|
||||||
android:offset="0.0" />
|
|
||||||
<item
|
|
||||||
android:color="#00000000"
|
|
||||||
android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#FFFFFF"
|
||||||
android:fillType="nonZero"
|
android:pathData="M44,50 C44,40 54,35 54,35 C64,35 64,50 64,50 L60,50 C60,43 54,39 54,39 C48,39 44,43 44,50 Z" />
|
||||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
<!-- Lock body: three bricks -->
|
||||||
android:strokeWidth="1"
|
<path
|
||||||
android:strokeColor="#00000000" />
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M42,52 H66 V59 H42 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M42,61 H66 V68 H42 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M42,70 H66 V78 C66,80 64,82 54,82 C44,82 42,80 42,78 Z" />
|
||||||
|
<!-- Side wall bricks -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M30,56 H38 V63 H30 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M30,66 H38 V73 H30 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M70,56 H78 V63 H70 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M70,66 H78 V73 H70 Z" />
|
||||||
</vector>
|
</vector>
|
||||||
31
app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
31
app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M44,50 C44,40 54,35 54,35 C64,35 64,50 64,50 L60,50 C60,43 54,39 54,39 C48,39 44,43 44,50 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M42,52 H66 V59 H42 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M42,61 H66 V68 H42 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M42,70 H66 V78 C66,80 64,82 54,82 C44,82 42,80 42,78 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M30,56 H38 V63 H30 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M30,66 H38 V73 H30 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M70,56 H78 V63 H70 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M70,66 H78 V73 H70 Z" />
|
||||||
|
</vector>
|
||||||
19
app/src/main/res/drawable/ic_stat_wallenc.xml
Normal file
19
app/src/main/res/drawable/ic_stat_wallenc.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<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,10 C9,8 10.5,7 12,7 C13.5,7 15,8 15,10 L14,10 C14,8.9 13.1,8 12,8 C10.9,8 10,8.9 10,10 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M9,11 H15 V13 H9 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M9,14 H15 V16 H9 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M9,17 H15 V19 C15,19.6 14.6,20 12,20 C9.4,20 9,19.6 9,19 Z" />
|
||||||
|
</vector>
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="splash_screen_background">#FFFFFFFF</color>
|
<color name="splash_screen_background">#FFF7F2FF</color>
|
||||||
|
<color name="launcher_background">#FF6650A4</color>
|
||||||
|
<color name="launcher_background_dark">#FF4A3F7A</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -7,5 +7,11 @@
|
|||||||
<item name="android:windowSplashScreenBackground" tools:targetApi="31">
|
<item name="android:windowSplashScreenBackground" tools:targetApi="31">
|
||||||
@color/splash_screen_background
|
@color/splash_screen_background
|
||||||
</item>
|
</item>
|
||||||
|
<item name="android:windowSplashScreenAnimatedIcon" tools:targetApi="31">
|
||||||
|
@drawable/ic_launcher_foreground
|
||||||
|
</item>
|
||||||
|
<item name="android:windowSplashScreenIconBackgroundColor" tools:targetApi="31">
|
||||||
|
@color/launcher_background
|
||||||
|
</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -58,11 +58,17 @@ class UnlockManager(
|
|||||||
allStorages.removeAt(allStorages.size - 1)
|
allStorages.removeAt(allStorages.size - 1)
|
||||||
allStorages.add(encStorage)
|
allStorages.add(encStorage)
|
||||||
}
|
}
|
||||||
catch (_: Exception) {
|
catch (e: WallencException.Storage.IncorrectKey) {
|
||||||
// ключ не подошёл
|
|
||||||
keysToRemove.add(key)
|
keysToRemove.add(key)
|
||||||
allStorages.removeAt(allStorages.size - 1)
|
allStorages.removeAt(allStorages.size - 1)
|
||||||
}
|
}
|
||||||
|
catch (_: WallencException.Storage.EncInfoMissing) {
|
||||||
|
keysToRemove.add(key)
|
||||||
|
allStorages.removeAt(allStorages.size - 1)
|
||||||
|
}
|
||||||
|
catch (_: Exception) {
|
||||||
|
allStorages.removeAt(allStorages.size - 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
keymapRepository.delete(*keysToRemove.toTypedArray()) // удалить мёртвые ключи
|
keymapRepository.delete(*keysToRemove.toTypedArray()) // удалить мёртвые ключи
|
||||||
_openedStorages.value = map.toMap()
|
_openedStorages.value = map.toMap()
|
||||||
@@ -104,6 +110,21 @@ class UnlockManager(
|
|||||||
return UUID(bb.long, bb.long)
|
return UUID(bb.long, bb.long)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun rememberKey(storage: IStorage, key: EncryptKey) = withContext(ioDispatcher) {
|
||||||
|
mutex.withLock {
|
||||||
|
val encInfo = storage.metaInfo.value.encInfo ?: throw WallencException.Storage.EncInfoMissing()
|
||||||
|
if (!Encryptor.checkKey(key, encInfo)) {
|
||||||
|
throw WallencException.Storage.IncorrectKey()
|
||||||
|
}
|
||||||
|
keymapRepository.add(
|
||||||
|
StorageKeyMap(
|
||||||
|
sourceUuid = storage.uuid,
|
||||||
|
key = key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun open(
|
override suspend fun open(
|
||||||
storage: IStorage,
|
storage: IStorage,
|
||||||
key: EncryptKey,
|
key: EncryptKey,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.github.nullptroma.wallenc.domain.vault.storages.common
|
|||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
|
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
|
||||||
|
import com.github.nullptroma.wallenc.domain.errors.WallencException
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
|
||||||
@@ -38,6 +40,10 @@ abstract class BaseStorage(
|
|||||||
final override val metaInfo: StateFlow<IStorageMetaInfo>
|
final override val metaInfo: StateFlow<IStorageMetaInfo>
|
||||||
get() = _metaInfo
|
get() = _metaInfo
|
||||||
|
|
||||||
|
private val _metaLoadState = MutableStateFlow(StorageMetaLoadState.Loading)
|
||||||
|
final override val metaLoadState: StateFlow<StorageMetaLoadState>
|
||||||
|
get() = _metaLoadState
|
||||||
|
|
||||||
final override val size: StateFlow<Long?>
|
final override val size: StateFlow<Long?>
|
||||||
get() = accessor.size
|
get() = accessor.size
|
||||||
|
|
||||||
@@ -70,19 +76,39 @@ abstract class BaseStorage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun readMetaInfo() = withContext(ioDispatcher) {
|
private suspend fun readMetaInfo() = withContext(ioDispatcher) {
|
||||||
var meta: CommonStorageMetaInfo
|
val (meta, state) = loadMetaFromDisk()
|
||||||
var reader: InputStream? = null
|
|
||||||
try {
|
|
||||||
reader = accessor.openReadSystemFile(metaInfoFileName)
|
|
||||||
meta = jackson.readValue(reader, CommonStorageMetaInfo::class.java)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// чтение не удалось — пишем дефолт, чтобы файл появился
|
|
||||||
meta = CommonStorageMetaInfo()
|
|
||||||
updateMetaInfo(meta)
|
|
||||||
} finally {
|
|
||||||
reader?.close()
|
|
||||||
}
|
|
||||||
_metaInfo.value = meta
|
_metaInfo.value = meta
|
||||||
|
_metaLoadState.value = state
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadMetaFromDisk(): Pair<IStorageMetaInfo, StorageMetaLoadState> {
|
||||||
|
return try {
|
||||||
|
val bytes = accessor.openReadSystemFile(metaInfoFileName).use { it.readBytes() }
|
||||||
|
when {
|
||||||
|
bytes.isEmpty() -> {
|
||||||
|
val default = CommonStorageMetaInfo()
|
||||||
|
updateMetaInfo(default)
|
||||||
|
default to StorageMetaLoadState.Ready
|
||||||
|
}
|
||||||
|
else -> try {
|
||||||
|
jackson.readValue(bytes, CommonStorageMetaInfo::class.java) to StorageMetaLoadState.Ready
|
||||||
|
} catch (_: Exception) {
|
||||||
|
CommonStorageMetaInfo() to StorageMetaLoadState.Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: WallencException.Storage.FileNotFound) {
|
||||||
|
val default = CommonStorageMetaInfo()
|
||||||
|
updateMetaInfo(default)
|
||||||
|
default to StorageMetaLoadState.Ready
|
||||||
|
} catch (_: Exception) {
|
||||||
|
CommonStorageMetaInfo() to StorageMetaLoadState.Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun requireMetaReady() {
|
||||||
|
if (_metaLoadState.value != StorageMetaLoadState.Ready) {
|
||||||
|
throw WallencException.Storage.NotAvailable()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateMetaInfo(meta: IStorageMetaInfo) = withContext(ioDispatcher) {
|
private suspend fun updateMetaInfo(meta: IStorageMetaInfo) = withContext(ioDispatcher) {
|
||||||
@@ -94,6 +120,7 @@ abstract class BaseStorage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
final override suspend fun rename(newName: String) = withContext(ioDispatcher) {
|
final override suspend fun rename(newName: String) = withContext(ioDispatcher) {
|
||||||
|
requireMetaReady()
|
||||||
val cur = metaInfo.value
|
val cur = metaInfo.value
|
||||||
updateMetaInfo(
|
updateMetaInfo(
|
||||||
CommonStorageMetaInfo(
|
CommonStorageMetaInfo(
|
||||||
@@ -104,6 +131,7 @@ abstract class BaseStorage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
final override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = withContext(ioDispatcher) {
|
final override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = withContext(ioDispatcher) {
|
||||||
|
requireMetaReady()
|
||||||
val cur = metaInfo.value
|
val cur = metaInfo.value
|
||||||
updateMetaInfo(
|
updateMetaInfo(
|
||||||
CommonStorageMetaInfo(
|
CommonStorageMetaInfo(
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.github.nullptroma.wallenc.domain.vault.storages.common
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.errors.WallencException
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
/** Читает системный файл; отсутствие файла — пустой массив байт (не исключение). */
|
||||||
|
internal suspend fun readSystemFileBytesOrEmpty(open: suspend () -> InputStream): ByteArray =
|
||||||
|
try {
|
||||||
|
open().use { it.readBytes() }
|
||||||
|
} catch (_: WallencException.Storage.FileNotFound) {
|
||||||
|
ByteArray(0)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.storages.encrypt
|
package com.github.nullptroma.wallenc.domain.vault.storages.encrypt
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.errors.WallencException
|
||||||
|
import com.github.nullptroma.wallenc.domain.vault.storages.common.readSystemFileBytesOrEmpty
|
||||||
import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosed
|
import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosed
|
||||||
import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosing
|
import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosing
|
||||||
import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory
|
import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory
|
||||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -272,12 +275,10 @@ class EncryptedStorageAccessor(
|
|||||||
|
|
||||||
override suspend fun openReadSystemFile(name: String): InputStream = scope.run {
|
override suspend fun openReadSystemFile(name: String): InputStream = scope.run {
|
||||||
val path = Path(systemHiddenDirName, name).pathString
|
val path = Path(systemHiddenDirName, name).pathString
|
||||||
return@run try {
|
try {
|
||||||
openRead(path)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Как у Yandex/Local: системного файла ещё нет — создаём пустой и читаем снова.
|
|
||||||
openWriteSystemFile(name).use { }
|
|
||||||
openRead(path)
|
openRead(path)
|
||||||
|
} catch (_: WallencException.Storage.FileNotFound) {
|
||||||
|
ByteArrayInputStream(ByteArray(0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +291,7 @@ class EncryptedStorageAccessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> {
|
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> {
|
||||||
val bytes = openReadSystemFile(SYNC_JOURNAL_FILENAME).use { it.readBytes() }
|
val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_JOURNAL_FILENAME) }
|
||||||
if (bytes.isEmpty()) {
|
if (bytes.isEmpty()) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
@@ -322,7 +323,7 @@ class EncryptedStorageAccessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun readSyncLock(): StorageSyncLock? {
|
override suspend fun readSyncLock(): StorageSyncLock? {
|
||||||
val bytes = openReadSystemFile(SYNC_LOCK_FILENAME).use { it.readBytes() }
|
val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_LOCK_FILENAME) }
|
||||||
if (bytes.isEmpty()) {
|
if (bytes.isEmpty()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.storages.local
|
package com.github.nullptroma.wallenc.domain.vault.storages.local
|
||||||
|
|
||||||
import com.github.nullptroma.wallenc.domain.errors.WallencException
|
import com.github.nullptroma.wallenc.domain.errors.WallencException
|
||||||
|
import com.github.nullptroma.wallenc.domain.vault.storages.common.readSystemFileBytesOrEmpty
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JacksonException
|
import com.fasterxml.jackson.core.JacksonException
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
@@ -553,11 +554,9 @@ class LocalStorageAccessor(
|
|||||||
val dirPath = _filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME)
|
val dirPath = _filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME)
|
||||||
val path = dirPath.resolve(name)
|
val path = dirPath.resolve(name)
|
||||||
val file = path.toFile()
|
val file = path.toFile()
|
||||||
if(!file.exists()) {
|
if (!file.exists()) {
|
||||||
Files.createDirectories(dirPath)
|
throw WallencException.Storage.FileNotFound()
|
||||||
file.createNewFile()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return@withContext file.inputStream()
|
return@withContext file.inputStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +573,7 @@ class LocalStorageAccessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> = withContext(ioDispatcher) {
|
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> = withContext(ioDispatcher) {
|
||||||
val bytes = openReadSystemFile(SYNC_JOURNAL_FILENAME).use { it.readBytes() }
|
val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_JOURNAL_FILENAME) }
|
||||||
if (bytes.isEmpty()) {
|
if (bytes.isEmpty()) {
|
||||||
return@withContext emptyList()
|
return@withContext emptyList()
|
||||||
}
|
}
|
||||||
@@ -604,7 +603,7 @@ class LocalStorageAccessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun readSyncLock(): StorageSyncLock? = withContext(ioDispatcher) {
|
override suspend fun readSyncLock(): StorageSyncLock? = withContext(ioDispatcher) {
|
||||||
val bytes = openReadSystemFile(SYNC_LOCK_FILENAME).use { it.readBytes() }
|
val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_LOCK_FILENAME) }
|
||||||
if (bytes.isEmpty()) {
|
if (bytes.isEmpty()) {
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.github.nullptroma.wallenc.domain.vault.storages.yandex
|
|||||||
|
|
||||||
import com.github.nullptroma.wallenc.domain.errors.WallencException
|
import com.github.nullptroma.wallenc.domain.errors.WallencException
|
||||||
import com.github.nullptroma.wallenc.domain.vault.errors.toVaultWallencException
|
import com.github.nullptroma.wallenc.domain.vault.errors.toVaultWallencException
|
||||||
|
import com.github.nullptroma.wallenc.domain.vault.storages.common.readSystemFileBytesOrEmpty
|
||||||
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskAuthException
|
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskAuthException
|
||||||
@@ -575,12 +576,11 @@ class YandexStorageAccessor(
|
|||||||
override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) {
|
override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) {
|
||||||
ensureSystemDirExists()
|
ensureSystemDirExists()
|
||||||
val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name"
|
val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name"
|
||||||
try {
|
val diskPath = toDiskPath(rel)
|
||||||
guard { repo.openDownloadStream(toDiskPath(rel)) }
|
when (guard { repo.getOrNull(diskPath) }?.type) {
|
||||||
} catch (_: Exception) {
|
"file" -> guard { repo.openDownloadStream(diskPath) }
|
||||||
// как Local: пустой файл если нет
|
null -> throw WallencException.Storage.FileNotFound()
|
||||||
guard { repo.uploadBytes(toDiskPath(rel), ByteArray(0), overwrite = true) }
|
else -> throw WallencException.Storage.FileNotFound()
|
||||||
guard { repo.openDownloadStream(toDiskPath(rel)) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,7 +598,7 @@ class YandexStorageAccessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> = withContext(ioDispatcher) {
|
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> = withContext(ioDispatcher) {
|
||||||
val bytes = openReadSystemFile(SYNC_JOURNAL_FILENAME).use { it.readBytes() }
|
val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_JOURNAL_FILENAME) }
|
||||||
if (bytes.isEmpty()) {
|
if (bytes.isEmpty()) {
|
||||||
return@withContext emptyList()
|
return@withContext emptyList()
|
||||||
}
|
}
|
||||||
@@ -632,7 +632,7 @@ class YandexStorageAccessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun readSyncLock(): StorageSyncLock? = withContext(ioDispatcher) {
|
override suspend fun readSyncLock(): StorageSyncLock? = withContext(ioDispatcher) {
|
||||||
val bytes = openReadSystemFile(SYNC_LOCK_FILENAME).use { it.readBytes() }
|
val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_LOCK_FILENAME) }
|
||||||
if (bytes.isEmpty()) {
|
if (bytes.isEmpty()) {
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,17 @@ class LocalVault(
|
|||||||
return@withContext storage
|
return@withContext storage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun rescanStorages() = withContext(ioDispatcher) {
|
||||||
|
_storagesScanInProgress.value = true
|
||||||
|
try {
|
||||||
|
if (_isAvailable.value) {
|
||||||
|
readStorages()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_storagesScanInProgress.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) {
|
override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) {
|
||||||
val path = path.value
|
val path = path.value
|
||||||
if (path == null || !_isAvailable.value) {
|
if (path == null || !_isAvailable.value) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskA
|
|||||||
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository
|
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository
|
||||||
import com.github.nullptroma.wallenc.domain.vault.storages.yandex.YandexStorage
|
import com.github.nullptroma.wallenc.domain.vault.storages.yandex.YandexStorage
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||||
import com.github.nullptroma.wallenc.vault.contract.CloudBrand
|
import com.github.nullptroma.wallenc.vault.contract.CloudBrand
|
||||||
import com.github.nullptroma.wallenc.vault.contract.DescribedVault
|
import com.github.nullptroma.wallenc.vault.contract.DescribedVault
|
||||||
@@ -13,9 +14,12 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -54,12 +58,20 @@ class YandexVault(
|
|||||||
private val _availableSpace = MutableStateFlow<Long?>(null)
|
private val _availableSpace = MutableStateFlow<Long?>(null)
|
||||||
override val availableSpace: StateFlow<Long?> = _availableSpace
|
override val availableSpace: StateFlow<Long?> = _availableSpace
|
||||||
|
|
||||||
|
private val refreshMutex = Mutex()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
parentScope.launch {
|
parentScope.launch {
|
||||||
runCatching { refreshFromDisk() }
|
runCatching { refreshFromDisk() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun rescanStorages() {
|
||||||
|
refreshMutex.withLock {
|
||||||
|
refreshFromDisk()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun refreshFromDisk() {
|
private suspend fun refreshFromDisk() {
|
||||||
_storagesScanInProgress.value = true
|
_storagesScanInProgress.value = true
|
||||||
_vaultReachable.value = false
|
_vaultReachable.value = false
|
||||||
@@ -111,13 +123,26 @@ class YandexVault(
|
|||||||
if (pending.isEmpty()) return emptyList()
|
if (pending.isEmpty()) return emptyList()
|
||||||
return coroutineScope {
|
return coroutineScope {
|
||||||
pending.map { storage ->
|
pending.map { storage ->
|
||||||
async(ioDispatcher) {
|
async(ioDispatcher) { initStorageWithRetry(storage) }
|
||||||
if (runCatching { storage.init() }.isSuccess) storage else null
|
|
||||||
}
|
|
||||||
}.awaitAll().filterNotNull()
|
}.awaitAll().filterNotNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun initStorageWithRetry(storage: YandexStorage): YandexStorage? {
|
||||||
|
for (attempt in 0 until STORAGE_INIT_ATTEMPTS) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
delay(STORAGE_INIT_RETRY_DELAY_MS * attempt)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
runCatching { storage.init() }.isSuccess &&
|
||||||
|
storage.metaLoadState.value == StorageMetaLoadState.Ready
|
||||||
|
) {
|
||||||
|
return storage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun createStorage(): IStorage = withContext(ioDispatcher) {
|
override suspend fun createStorage(): IStorage = withContext(ioDispatcher) {
|
||||||
val id = UUID.randomUUID()
|
val id = UUID.randomUUID()
|
||||||
repo.createFolder("app:/$id")
|
repo.createFolder("app:/$id")
|
||||||
@@ -150,5 +175,7 @@ class YandexVault(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
private const val APP_LIST_LIMIT = 1000
|
private const val APP_LIST_LIMIT = 1000
|
||||||
|
private const val STORAGE_INIT_ATTEMPTS = 3
|
||||||
|
private const val STORAGE_INIT_RETRY_DELAY_MS = 400L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.github.nullptroma.wallenc.domain.datatypes
|
||||||
|
|
||||||
|
enum class StorageMetaLoadState {
|
||||||
|
Loading,
|
||||||
|
Ready,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.interfaces
|
package com.github.nullptroma.wallenc.domain.interfaces
|
||||||
|
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
|
||||||
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
|
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -14,6 +15,7 @@ sealed interface IStorageInfo {
|
|||||||
val numberOfFiles: StateFlow<Int?>
|
val numberOfFiles: StateFlow<Int?>
|
||||||
val isEmpty: Flow<Boolean?>
|
val isEmpty: Flow<Boolean?>
|
||||||
val metaInfo: StateFlow<IStorageMetaInfo>
|
val metaInfo: StateFlow<IStorageMetaInfo>
|
||||||
|
val metaLoadState: StateFlow<StorageMetaLoadState>
|
||||||
val isVirtualStorage: Boolean
|
val isVirtualStorage: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface IUnlockManager {
|
|||||||
fun getOpenedStorageKey(uuid: UUID): EncryptKey?
|
fun getOpenedStorageKey(uuid: UUID): EncryptKey?
|
||||||
|
|
||||||
suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean = true): IStorage
|
suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean = true): IStorage
|
||||||
|
/** Сохранить ключ для auto-open без открытия виртуального storage. */
|
||||||
|
suspend fun rememberKey(storage: IStorage, key: EncryptKey)
|
||||||
suspend fun close(storage: IStorage)
|
suspend fun close(storage: IStorage)
|
||||||
suspend fun close(uuid: UUID)
|
suspend fun close(uuid: UUID)
|
||||||
}
|
}
|
||||||
@@ -21,4 +21,7 @@ interface IVault : IVaultInfo {
|
|||||||
suspend fun createStorage(): IStorage
|
suspend fun createStorage(): IStorage
|
||||||
suspend fun createStorage(enc: StorageEncryptionInfo): IStorage
|
suspend fun createStorage(enc: StorageEncryptionInfo): IStorage
|
||||||
suspend fun remove(storage: IStorage)
|
suspend fun remove(storage: IStorage)
|
||||||
|
|
||||||
|
/** Пересканировать список storages (для удалённых vault — повторный листинг и init). */
|
||||||
|
suspend fun rescanStorages() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ enum class VaultTaskStep {
|
|||||||
AddRemoteVault,
|
AddRemoteVault,
|
||||||
RemoveRemoteVault,
|
RemoveRemoteVault,
|
||||||
RetryRemoteVault,
|
RetryRemoteVault,
|
||||||
|
RescanVaultStorages,
|
||||||
Save2FaToken,
|
Save2FaToken,
|
||||||
Delete2FaToken,
|
Delete2FaToken,
|
||||||
SaveTextSecret,
|
SaveTextSecret,
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
|||||||
#Sat Sep 07 01:04:14 MSK 2024
|
#Sat Sep 07 01:04:14 MSK 2024
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -4,17 +4,15 @@ import android.content.Intent
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.List
|
import androidx.compose.material.icons.automirrored.rounded.List
|
||||||
import androidx.compose.material.icons.rounded.Menu
|
import androidx.compose.material.icons.rounded.Menu
|
||||||
import androidx.compose.material.icons.rounded.Settings
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
import androidx.compose.material.icons.rounded.Sync
|
import androidx.compose.material.icons.rounded.Sync
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.NavigationBar
|
|
||||||
import androidx.compose.material3.NavigationBarItem
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -22,16 +20,16 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.navDeepLink
|
import androidx.navigation.navDeepLink
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.FloatingWallencNavigationBar
|
||||||
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
|
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
|
||||||
import com.github.nullptroma.wallenc.ui.navigation.WallencDeepLinks
|
import com.github.nullptroma.wallenc.ui.navigation.WallencDeepLinks
|
||||||
import com.github.nullptroma.wallenc.ui.navigation.matchesWallencDeepLink
|
import com.github.nullptroma.wallenc.ui.navigation.matchesWallencDeepLink
|
||||||
import com.github.nullptroma.wallenc.ui.elements.NavigationBarMarqueeText
|
|
||||||
import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState
|
import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState
|
||||||
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
|
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
|
||||||
import com.github.nullptroma.wallenc.ui.screens.main.MainScreen
|
import com.github.nullptroma.wallenc.ui.screens.main.MainScreen
|
||||||
@@ -108,43 +106,36 @@ fun WallencNavRoot(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Scaffold(bottomBar = {
|
|
||||||
NavigationBar(modifier = Modifier.wrapContentHeight()) {
|
|
||||||
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
|
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
topLevelNavBarItems.forEach {
|
|
||||||
val routeClassName = it.key
|
Scaffold(
|
||||||
val navBarItemData = it.value
|
bottomBar = {
|
||||||
NavigationBarItem(
|
Box(
|
||||||
modifier = Modifier.wrapContentHeight(),
|
modifier = Modifier
|
||||||
icon = {
|
.navigationBarsPadding()
|
||||||
if (navBarItemData.icon != null) Icon(
|
.padding(horizontal = 12.dp)
|
||||||
navBarItemData.icon,
|
.padding(top = 4.dp, bottom = 6.dp),
|
||||||
contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId),
|
) {
|
||||||
)
|
FloatingWallencNavigationBar(
|
||||||
|
items = topLevelNavBarItems,
|
||||||
|
routes = topLevelRoutes,
|
||||||
|
currentRoute = currentRoute,
|
||||||
|
onNavigate = { item ->
|
||||||
|
val route = topLevelRoutes[item.screenRouteClass]
|
||||||
|
?: error("Route ${item.screenRouteClass} not found")
|
||||||
|
if (currentRoute?.startsWith(item.screenRouteClass) != true) {
|
||||||
|
navState.changeTop(route)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
label = {
|
|
||||||
NavigationBarMarqueeText(
|
|
||||||
text = stringResource(navBarItemData.nameStringResourceId),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
selected = currentRoute?.startsWith(routeClassName) == true,
|
) { innerPaddings ->
|
||||||
onClick = {
|
|
||||||
val route = topLevelRoutes[navBarItemData.screenRouteClass]
|
|
||||||
if (route == null)
|
|
||||||
throw NullPointerException("Route $route not found")
|
|
||||||
if (currentRoute?.startsWith(routeClassName) != true) navState.changeTop(
|
|
||||||
route
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { innerPaddings ->
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navState.navHostController,
|
navState.navHostController,
|
||||||
startDestination = topLevelRoutes[MainRoute::class.qualifiedName]!!
|
startDestination = topLevelRoutes[MainRoute::class.qualifiedName]!!,
|
||||||
|
modifier = Modifier.padding(innerPaddings),
|
||||||
) {
|
) {
|
||||||
composable<MainRoute>(
|
composable<MainRoute>(
|
||||||
deepLinks = listOf(
|
deepLinks = listOf(
|
||||||
@@ -152,13 +143,15 @@ fun WallencNavRoot(
|
|||||||
),
|
),
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
fadeIn(tween(200))
|
fadeIn(tween(200))
|
||||||
}, exitTransition = {
|
},
|
||||||
|
exitTransition = {
|
||||||
fadeOut(tween(200))
|
fadeOut(tween(200))
|
||||||
}) {
|
},
|
||||||
|
) {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
modifier = Modifier.padding(innerPaddings),
|
modifier = Modifier,
|
||||||
navState = mainNavState,
|
navState = mainNavState,
|
||||||
viewModel = mainViewModel
|
viewModel = mainViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable<SettingsRoute>(
|
composable<SettingsRoute>(
|
||||||
@@ -167,10 +160,12 @@ fun WallencNavRoot(
|
|||||||
),
|
),
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
fadeIn(tween(200))
|
fadeIn(tween(200))
|
||||||
}, exitTransition = {
|
},
|
||||||
|
exitTransition = {
|
||||||
fadeOut(tween(200))
|
fadeOut(tween(200))
|
||||||
}) {
|
},
|
||||||
SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel)
|
) {
|
||||||
|
SettingsScreen(Modifier, settingsViewModel)
|
||||||
}
|
}
|
||||||
composable<StorageSyncRoute>(
|
composable<StorageSyncRoute>(
|
||||||
deepLinks = listOf(
|
deepLinks = listOf(
|
||||||
@@ -178,11 +173,13 @@ fun WallencNavRoot(
|
|||||||
),
|
),
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
fadeIn(tween(200))
|
fadeIn(tween(200))
|
||||||
}, exitTransition = {
|
},
|
||||||
|
exitTransition = {
|
||||||
fadeOut(tween(200))
|
fadeOut(tween(200))
|
||||||
}) {
|
},
|
||||||
|
) {
|
||||||
StorageSyncScreen(
|
StorageSyncScreen(
|
||||||
modifier = Modifier.padding(innerPaddings),
|
modifier = Modifier,
|
||||||
viewModel = storageSyncViewModel,
|
viewModel = storageSyncViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -192,12 +189,12 @@ fun WallencNavRoot(
|
|||||||
),
|
),
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
fadeIn(tween(200))
|
fadeIn(tween(200))
|
||||||
}, exitTransition = {
|
},
|
||||||
|
exitTransition = {
|
||||||
fadeOut(tween(200))
|
fadeOut(tween(200))
|
||||||
}) {
|
},
|
||||||
TaskPipelineScreen(
|
) {
|
||||||
modifier = Modifier.padding(innerPaddings)
|
TaskPipelineScreen(modifier = Modifier)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.github.nullptroma.wallenc.ui.elements
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
|
|
||||||
|
private const val BackButtonAnimMillis = 200
|
||||||
|
|
||||||
|
private val backButtonEnter = fadeIn(tween(BackButtonAnimMillis)) +
|
||||||
|
slideInVertically(
|
||||||
|
animationSpec = tween(BackButtonAnimMillis),
|
||||||
|
initialOffsetY = { fullHeight -> fullHeight / 2 },
|
||||||
|
)
|
||||||
|
|
||||||
|
private val backButtonExit = fadeOut(tween(BackButtonAnimMillis)) +
|
||||||
|
slideOutVertically(
|
||||||
|
animationSpec = tween(BackButtonAnimMillis),
|
||||||
|
targetOffsetY = { fullHeight -> -fullHeight / 2 },
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun FloatingBackButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.size(44.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shadowElevation = 4.dp,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.nav_cd_back),
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AnimatedFloatingBackButton(
|
||||||
|
visible: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
modifier = modifier,
|
||||||
|
enter = backButtonEnter,
|
||||||
|
exit = backButtonExit,
|
||||||
|
) {
|
||||||
|
FloatingBackButton(onClick = onClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package com.github.nullptroma.wallenc.ui.elements
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
|
||||||
|
|
||||||
|
/** Вертикальный зазор между вложенной и корневой плавающими панелями навигации. */
|
||||||
|
val WallencNestedNavBarGap = 2.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FloatingWallencNavigationBar(
|
||||||
|
items: Map<String, NavBarItemData>,
|
||||||
|
routes: Map<String, *>,
|
||||||
|
currentRoute: String?,
|
||||||
|
onNavigate: (NavBarItemData) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
compact: Boolean = false,
|
||||||
|
) {
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
val barHeight = if (compact) 48.dp else 56.dp
|
||||||
|
val barShape = if (compact) RoundedCornerShape(22.dp) else RoundedCornerShape(28.dp)
|
||||||
|
val barHorizontalPadding = if (compact) 4.dp else 6.dp
|
||||||
|
|
||||||
|
val barSurface: @Composable () -> Unit = {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.then(
|
||||||
|
if (compact) {
|
||||||
|
Modifier
|
||||||
|
.widthIn(max = 300.dp)
|
||||||
|
.fillMaxWidth(0.68f)
|
||||||
|
} else {
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
shape = barShape,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shadowElevation = 6.dp,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(barHeight)
|
||||||
|
.padding(horizontal = barHorizontalPadding, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
items.forEach { (routeClassName, navBarItemData) ->
|
||||||
|
val iconVector = navBarItemData.icon ?: return@forEach
|
||||||
|
val selected = currentRoute?.startsWith(routeClassName) == true
|
||||||
|
val enabled = routes[navBarItemData.screenRouteClass] != null
|
||||||
|
FloatingNavItem(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
icon = iconVector,
|
||||||
|
label = stringResource(navBarItemData.nameStringResourceId),
|
||||||
|
contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId),
|
||||||
|
selected = selected,
|
||||||
|
enabled = enabled,
|
||||||
|
compact = compact,
|
||||||
|
onClick = {
|
||||||
|
if (!selected && enabled) {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
onNavigate(navBarItemData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
barSurface()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
|
barSurface()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun FloatingNavItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
contentDescription: String,
|
||||||
|
selected: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
compact: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val iconSize = if (compact) 22.dp else 24.dp
|
||||||
|
val itemPaddingH = if (compact) 6.dp else 8.dp
|
||||||
|
val itemPaddingV = if (compact) 6.dp else 8.dp
|
||||||
|
val labelStyle = if (compact) {
|
||||||
|
MaterialTheme.typography.labelSmall
|
||||||
|
} else {
|
||||||
|
MaterialTheme.typography.labelMedium
|
||||||
|
}
|
||||||
|
val labelVelocity = if (compact) 24.dp else 28.dp
|
||||||
|
val itemShape = if (compact) RoundedCornerShape(16.dp) else RoundedCornerShape(20.dp)
|
||||||
|
val containerColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
val contentColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier
|
||||||
|
.padding(horizontal = if (compact) 1.dp else 2.dp)
|
||||||
|
.semantics {
|
||||||
|
role = Role.Tab
|
||||||
|
this.contentDescription = contentDescription
|
||||||
|
},
|
||||||
|
shape = itemShape,
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = itemPaddingH, vertical = itemPaddingV),
|
||||||
|
horizontalArrangement = if (selected) Arrangement.Start else Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(iconSize),
|
||||||
|
)
|
||||||
|
if (selected) {
|
||||||
|
NavigationBarMarqueeText(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(start = if (compact) 4.dp else 6.dp),
|
||||||
|
style = labelStyle,
|
||||||
|
velocity = labelVelocity,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,25 +7,33 @@ import androidx.compose.material3.LocalTextStyle
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Однострочная подпись таба нижней навигации: при нехватке ширины текст
|
* Однострочная подпись таба: при нехватке ширины текст циклически прокручивается.
|
||||||
* прокручивается (marquee), без переноса последних букв на вторую строку.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NavigationBarMarqueeText(
|
fun NavigationBarMarqueeText(
|
||||||
text: String,
|
text: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
style: TextStyle = LocalTextStyle.current,
|
||||||
|
velocity: Dp = 28.dp,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.basicMarquee(),
|
.basicMarquee(
|
||||||
style = LocalTextStyle.current,
|
iterations = Int.MAX_VALUE,
|
||||||
|
repeatDelayMillis = 1_200,
|
||||||
|
velocity = velocity,
|
||||||
|
),
|
||||||
|
style = style,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
softWrap = false,
|
softWrap = false,
|
||||||
overflow = TextOverflow.Clip,
|
overflow = TextOverflow.Clip,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.Tree
|
import com.github.nullptroma.wallenc.domain.datatypes.Tree
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
@@ -74,7 +75,10 @@ fun StorageTree(
|
|||||||
val numOfFiles by cur.numberOfFiles.collectAsStateWithLifecycle()
|
val numOfFiles by cur.numberOfFiles.collectAsStateWithLifecycle()
|
||||||
val size by cur.size.collectAsStateWithLifecycle()
|
val size by cur.size.collectAsStateWithLifecycle()
|
||||||
val metaInfo by cur.metaInfo.collectAsStateWithLifecycle()
|
val metaInfo by cur.metaInfo.collectAsStateWithLifecycle()
|
||||||
|
val metaLoadState by cur.metaLoadState.collectAsStateWithLifecycle()
|
||||||
val isAvailable by cur.isAvailable.collectAsStateWithLifecycle()
|
val isAvailable by cur.isAvailable.collectAsStateWithLifecycle()
|
||||||
|
val metaUnavailable = metaLoadState == StorageMetaLoadState.Unavailable
|
||||||
|
val rowEnabled = isAvailable && !rowBusy && !metaUnavailable
|
||||||
val isEncrypted = metaInfo.encInfo != null
|
val isEncrypted = metaInfo.encInfo != null
|
||||||
val isOpened = isEncryptionOpened(tree)
|
val isOpened = isEncryptionOpened(tree)
|
||||||
val borderColor =
|
val borderColor =
|
||||||
@@ -82,6 +86,7 @@ fun StorageTree(
|
|||||||
val yesWord = stringResource(R.string.storage_value_yes)
|
val yesWord = stringResource(R.string.storage_value_yes)
|
||||||
val noWord = stringResource(R.string.storage_value_no)
|
val noWord = stringResource(R.string.storage_value_no)
|
||||||
val unavailableHint = stringResource(R.string.storage_unavailable_hint)
|
val unavailableHint = stringResource(R.string.storage_unavailable_hint)
|
||||||
|
val metaUnavailableHint = stringResource(R.string.storage_meta_unavailable_hint)
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -112,9 +117,9 @@ fun StorageTree(
|
|||||||
elevation = CardDefaults.cardElevation(
|
elevation = CardDefaults.cardElevation(
|
||||||
defaultElevation = 4.dp,
|
defaultElevation = 4.dp,
|
||||||
),
|
),
|
||||||
enabled = isAvailable && !rowBusy,
|
enabled = rowEnabled,
|
||||||
onClick = debouncedLambda(debounceMs = 500) {
|
onClick = debouncedLambda(debounceMs = 500) {
|
||||||
if (isAvailable && !rowBusy) {
|
if (rowEnabled) {
|
||||||
onClick(tree)
|
onClick(tree)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -150,7 +155,13 @@ fun StorageTree(
|
|||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
if (!isAvailable) {
|
if (metaUnavailable) {
|
||||||
|
Text(
|
||||||
|
text = metaUnavailableHint,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
} else if (!isAvailable) {
|
||||||
Text(
|
Text(
|
||||||
text = unavailableHint,
|
text = unavailableHint,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -191,7 +202,7 @@ fun StorageTree(
|
|||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { expanded = !expanded },
|
onClick = { expanded = !expanded },
|
||||||
enabled = isAvailable && !rowBusy,
|
enabled = rowEnabled,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.MoreVert,
|
Icons.Default.MoreVert,
|
||||||
@@ -210,10 +221,10 @@ fun StorageTree(
|
|||||||
onDismissRequest = { expanded = false },
|
onDismissRequest = { expanded = false },
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
enabled = isAvailable && !rowBusy,
|
enabled = rowEnabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
expanded = false
|
expanded = false
|
||||||
if (isAvailable && !rowBusy) showRenameDialog = true
|
if (rowEnabled) showRenameDialog = true
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
@@ -230,10 +241,10 @@ fun StorageTree(
|
|||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
enabled = isAvailable && !rowBusy,
|
enabled = rowEnabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
expanded = false
|
expanded = false
|
||||||
if (isAvailable && !rowBusy) showRemoveConfirmDialog = true
|
if (rowEnabled) showRemoveConfirmDialog = true
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
@@ -251,10 +262,10 @@ fun StorageTree(
|
|||||||
if (!isEncrypted) {
|
if (!isEncrypted) {
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
enabled = isAvailable && !rowBusy,
|
enabled = rowEnabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
expanded = false
|
expanded = false
|
||||||
if (isAvailable && !rowBusy) showSetupEncryptionDialog = true
|
if (rowEnabled) showSetupEncryptionDialog = true
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
@@ -361,7 +372,7 @@ fun StorageTree(
|
|||||||
if (isEncrypted) {
|
if (isEncrypted) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { showLockDialog = true },
|
onClick = { showLockDialog = true },
|
||||||
enabled = isAvailable && !rowBusy,
|
enabled = rowEnabled,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
if (isOpened) Icons.Default.LockOpen else Icons.Default.Lock,
|
if (isOpened) Icons.Default.LockOpen else Icons.Default.Lock,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.github.nullptroma.wallenc.ui.elements
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.FabPosition
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WallencScreenScaffold(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
snackbarHostState: SnackbarHostState? = null,
|
||||||
|
floatingActionButton: @Composable () -> Unit = {},
|
||||||
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
contentWindowInsets = WindowInsets(0.dp),
|
||||||
|
snackbarHost = {
|
||||||
|
if (snackbarHostState != null) {
|
||||||
|
SnackbarHost(snackbarHostState)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
floatingActionButton = floatingActionButton,
|
||||||
|
floatingActionButtonPosition = FabPosition.End,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WallencScreenContentPadding(
|
||||||
|
innerPadding: PaddingValues,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.github.nullptroma.wallenc.ui.navigation
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute
|
||||||
|
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultRoute
|
||||||
|
import com.github.nullptroma.wallenc.ui.screens.shared.TextEditRoute
|
||||||
|
|
||||||
|
private val mainTopLevelRoutePrefixes: Set<String> = setOf(
|
||||||
|
LocalVaultRoute::class.qualifiedName!!,
|
||||||
|
RemoteVaultsRoute::class.qualifiedName!!,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun isMainTopLevelRoute(route: String?): Boolean {
|
||||||
|
if (route == null) return true
|
||||||
|
return mainTopLevelRoutePrefixes.any { route.startsWith(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isTextEditDestination(route: String?): Boolean {
|
||||||
|
val qualified = TextEditRoute::class.qualifiedName ?: return false
|
||||||
|
return route?.startsWith(qualified) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldShowMainFloatingBack(route: String?): Boolean {
|
||||||
|
if (route == null) return false
|
||||||
|
return !isMainTopLevelRoute(route)
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ class NavigationState(
|
|||||||
restoreState = true
|
restoreState = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun pop(): Boolean = navHostController.popBackStack()
|
||||||
|
|
||||||
|
fun canPop(): Boolean = navHostController.previousBackStackEntry != null
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ fun TaskProgressLabel.resolve(resolver: UiStringResolver): String = when (this)
|
|||||||
VaultTaskStep.AddRemoteVault -> resolver(R.string.task_progress_add_remote_vault)
|
VaultTaskStep.AddRemoteVault -> resolver(R.string.task_progress_add_remote_vault)
|
||||||
VaultTaskStep.RemoveRemoteVault -> resolver(R.string.task_progress_remove_remote_vault)
|
VaultTaskStep.RemoveRemoteVault -> resolver(R.string.task_progress_remove_remote_vault)
|
||||||
VaultTaskStep.RetryRemoteVault -> resolver(R.string.task_progress_retry_remote_vault)
|
VaultTaskStep.RetryRemoteVault -> resolver(R.string.task_progress_retry_remote_vault)
|
||||||
|
VaultTaskStep.RescanVaultStorages -> resolver(R.string.task_progress_rescan_vault_storages)
|
||||||
VaultTaskStep.Save2FaToken -> resolver(R.string.task_progress_save_2fa_token)
|
VaultTaskStep.Save2FaToken -> resolver(R.string.task_progress_save_2fa_token)
|
||||||
VaultTaskStep.Delete2FaToken -> resolver(R.string.task_progress_delete_2fa_token)
|
VaultTaskStep.Delete2FaToken -> resolver(R.string.task_progress_delete_2fa_token)
|
||||||
VaultTaskStep.SaveTextSecret -> resolver(R.string.task_progress_save_text_secret)
|
VaultTaskStep.SaveTextSecret -> resolver(R.string.task_progress_save_text_secret)
|
||||||
|
|||||||
@@ -3,34 +3,38 @@ package com.github.nullptroma.wallenc.ui.screens.main
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Cloud
|
import androidx.compose.material.icons.outlined.Cloud
|
||||||
import androidx.compose.material.icons.outlined.Folder
|
import androidx.compose.material.icons.outlined.Folder
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.NavigationBar
|
|
||||||
import androidx.compose.material3.NavigationBarItem
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.github.nullptroma.wallenc.ui.elements.NavigationBarMarqueeText
|
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.AnimatedFloatingBackButton
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.FloatingWallencNavigationBar
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencNestedNavBarGap
|
||||||
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
|
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
|
||||||
import com.github.nullptroma.wallenc.ui.navigation.NavigationState
|
import com.github.nullptroma.wallenc.ui.navigation.NavigationState
|
||||||
|
import com.github.nullptroma.wallenc.ui.navigation.isMainTopLevelRoute
|
||||||
|
import com.github.nullptroma.wallenc.ui.navigation.isTextEditDestination
|
||||||
|
import com.github.nullptroma.wallenc.ui.navigation.shouldShowMainFloatingBack
|
||||||
import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState
|
import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState
|
||||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute
|
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute
|
||||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsScreen
|
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsScreen
|
||||||
@@ -54,13 +58,8 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.VaultBrowserS
|
|||||||
import com.github.nullptroma.wallenc.ui.screens.shared.TextEditRoute
|
import com.github.nullptroma.wallenc.ui.screens.shared.TextEditRoute
|
||||||
import com.github.nullptroma.wallenc.ui.screens.shared.TextEditScreen
|
import com.github.nullptroma.wallenc.ui.screens.shared.TextEditScreen
|
||||||
|
|
||||||
private fun isTextEditDestination(route: String?): Boolean {
|
|
||||||
val q = TextEditRoute::class.qualifiedName ?: return false
|
|
||||||
return route?.startsWith(q) == true
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@androidx.compose.runtime.Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MainViewModel = hiltViewModel(),
|
viewModel: MainViewModel = hiltViewModel(),
|
||||||
@@ -72,8 +71,12 @@ fun MainScreen(
|
|||||||
val remoteVaultsViewModel: RemoteVaultsViewModel = hiltViewModel()
|
val remoteVaultsViewModel: RemoteVaultsViewModel = hiltViewModel()
|
||||||
|
|
||||||
val childBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
|
val childBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
|
||||||
val showWorkStatusBar = !isTextEditDestination(childBackStackEntry?.destination?.route)
|
val childRoute = childBackStackEntry?.destination?.route
|
||||||
|
val showWorkStatusBar = !isTextEditDestination(childRoute)
|
||||||
|
val showMainBottomNav = isMainTopLevelRoute(childRoute)
|
||||||
|
val showFloatingBack = shouldShowMainFloatingBack(childRoute) && navState.canPop()
|
||||||
val workStatus = mainUi.workStatus
|
val workStatus = mainUi.workStatus
|
||||||
|
val onBack: () -> Unit = { navState.pop() }
|
||||||
|
|
||||||
val topLevelNavBarItems = remember {
|
val topLevelNavBarItems = remember {
|
||||||
mapOf(
|
mapOf(
|
||||||
@@ -101,49 +104,36 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
Column {
|
if (showMainBottomNav) {
|
||||||
NavigationBar(windowInsets = WindowInsets(0)) {
|
Box(
|
||||||
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
|
modifier = Modifier
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
.padding(horizontal = 16.dp)
|
||||||
topLevelNavBarItems.forEach {
|
.padding(top = WallencNestedNavBarGap, bottom = WallencNestedNavBarGap),
|
||||||
val routeClassName = it.key
|
) {
|
||||||
val navBarItemData = it.value
|
FloatingWallencNavigationBar(
|
||||||
val iconVector = navBarItemData.icon
|
compact = true,
|
||||||
?: error("Main tab requires icon")
|
items = topLevelNavBarItems,
|
||||||
NavigationBarItem(
|
routes = routes,
|
||||||
modifier = Modifier.weight(1f),
|
currentRoute = childRoute,
|
||||||
icon = {
|
onNavigate = { item ->
|
||||||
Icon(
|
val route = routes[item.screenRouteClass]
|
||||||
imageVector = iconVector,
|
?: error("Route ${item.screenRouteClass} not found")
|
||||||
contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
NavigationBarMarqueeText(
|
|
||||||
text = stringResource(navBarItemData.nameStringResourceId),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
selected = currentRoute?.startsWith(routeClassName) == true,
|
|
||||||
onClick = {
|
|
||||||
val route = routes[navBarItemData.screenRouteClass]
|
|
||||||
?: throw NullPointerException("Route ${navBarItemData.screenRouteClass} not found")
|
|
||||||
if (currentRoute?.startsWith(routeClassName) != true) {
|
|
||||||
navState.changeTop(route)
|
navState.changeTop(route)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { innerPaddings ->
|
) { innerPaddings ->
|
||||||
NavHost(
|
Box(
|
||||||
navController = navState.navHostController,
|
|
||||||
startDestination = routes[LocalVaultRoute::class.qualifiedName]!!,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPaddings),
|
.padding(innerPaddings),
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
navController = navState.navHostController,
|
||||||
|
startDestination = routes[LocalVaultRoute::class.qualifiedName]!!,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
composable<LocalVaultRoute>(
|
composable<LocalVaultRoute>(
|
||||||
enterTransition = { fadeIn(tween(200)) },
|
enterTransition = { fadeIn(tween(200)) },
|
||||||
@@ -242,9 +232,7 @@ fun MainScreen(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onDeleted = {
|
onDeleted = { navState.pop() },
|
||||||
navState.navHostController.popBackStack()
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable<TextSecretEditRoute>(
|
composable<TextSecretEditRoute>(
|
||||||
@@ -255,7 +243,7 @@ fun MainScreen(
|
|||||||
TextSecretEditScreen(
|
TextSecretEditScreen(
|
||||||
onSaved = { savedSecretId ->
|
onSaved = { savedSecretId ->
|
||||||
val editingExisting = route.secretId != null
|
val editingExisting = route.secretId != null
|
||||||
navState.navHostController.popBackStack()
|
navState.pop()
|
||||||
if (!editingExisting) {
|
if (!editingExisting) {
|
||||||
navState.push(
|
navState.push(
|
||||||
TextSecretDetailsRoute(
|
TextSecretDetailsRoute(
|
||||||
@@ -269,8 +257,18 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
composable<TextEditRoute> {
|
composable<TextEditRoute> {
|
||||||
val route: TextEditRoute = it.toRoute()
|
val route: TextEditRoute = it.toRoute()
|
||||||
TextEditScreen(route.text)
|
TextEditScreen(text = route.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AnimatedFloatingBackButton(
|
||||||
|
visible = showFloatingBack,
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier
|
||||||
|
.zIndex(1f)
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(start = 12.dp, bottom = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
|
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -16,10 +16,10 @@ import androidx.compose.material3.CardDefaults
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -27,6 +27,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold
|
||||||
import com.github.nullptroma.wallenc.ui.resources.resolveText
|
import com.github.nullptroma.wallenc.ui.resources.resolveText
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -38,19 +40,19 @@ fun StorageHomeScreen(
|
|||||||
) {
|
) {
|
||||||
val uiState by viewModel.state.collectAsStateWithLifecycle()
|
val uiState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
WallencScreenScaffold(modifier = modifier) { innerPadding ->
|
||||||
modifier = modifier,
|
WallencScreenContentPadding(innerPadding) {
|
||||||
contentWindowInsets = WindowInsets(0.dp),
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
return@Column
|
return@Column
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +113,7 @@ fun StorageHomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
|
|||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
|
||||||
import com.github.nullptroma.wallenc.domain.errors.WallencException
|
import com.github.nullptroma.wallenc.domain.errors.WallencException
|
||||||
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
import com.github.nullptroma.wallenc.ui.ViewModelBase
|
import com.github.nullptroma.wallenc.ui.ViewModelBase
|
||||||
|
import com.github.nullptroma.wallenc.ui.resources.UserNotification
|
||||||
import com.github.nullptroma.wallenc.ui.resources.toUserNotification
|
import com.github.nullptroma.wallenc.ui.resources.toUserNotification
|
||||||
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
|
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
|
||||||
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
|
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
|
||||||
@@ -43,11 +46,13 @@ class StorageHomeViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
storage.isAvailable,
|
storage.isAvailable,
|
||||||
storage.metaInfo,
|
storage.metaInfo,
|
||||||
|
storage.metaLoadState,
|
||||||
manageTwoFaTokensUseCase.observe(storage),
|
manageTwoFaTokensUseCase.observe(storage),
|
||||||
manageTextSecretsUseCase.observe(storage),
|
manageTextSecretsUseCase.observe(storage),
|
||||||
) { available, meta, twoFa, secrets ->
|
) { available, meta, metaState, twoFa, secrets ->
|
||||||
|
val metaUnavailable = metaState == StorageMetaLoadState.Unavailable
|
||||||
val isRawEncrypted = meta.encInfo != null && !storage.isVirtualStorage
|
val isRawEncrypted = meta.encInfo != null && !storage.isVirtualStorage
|
||||||
val canManageDomainData = available && !isRawEncrypted
|
val canManageDomainData = available && !isRawEncrypted && !metaUnavailable
|
||||||
state.value.copy(
|
state.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
storageUuid = storage.uuid.toString(),
|
storageUuid = storage.uuid.toString(),
|
||||||
@@ -58,10 +63,10 @@ class StorageHomeViewModel @Inject constructor(
|
|||||||
twoFaCount = twoFa.size,
|
twoFaCount = twoFa.size,
|
||||||
textSecretsCount = secrets.size,
|
textSecretsCount = secrets.size,
|
||||||
canManageDomainData = canManageDomainData,
|
canManageDomainData = canManageDomainData,
|
||||||
errorNotification = if (isRawEncrypted) {
|
errorNotification = when {
|
||||||
WallencException.Feature.NeedsDecryptedView().toUserNotification()
|
metaUnavailable -> UserNotification.TextRes(R.string.storage_home_meta_unavailable)
|
||||||
} else {
|
isRawEncrypted -> WallencException.Feature.NeedsDecryptedView().toUserNotification()
|
||||||
null
|
else -> null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}.collect { ui ->
|
}.collect { ui ->
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.content.ClipData
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -19,7 +18,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -33,6 +31,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold
|
||||||
import com.github.nullptroma.wallenc.ui.resources.resolveText
|
import com.github.nullptroma.wallenc.ui.resources.resolveText
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -48,15 +48,10 @@ fun TextSecretDetailsScreen(
|
|||||||
val clipboard = LocalClipboard.current
|
val clipboard = LocalClipboard.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Scaffold(
|
WallencScreenScaffold(modifier = modifier) { innerPadding ->
|
||||||
modifier = modifier,
|
WallencScreenContentPadding(innerPadding) {
|
||||||
contentWindowInsets = WindowInsets(0.dp),
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
uiState.errorNotification?.let { notification ->
|
uiState.errorNotification?.let { notification ->
|
||||||
@@ -145,4 +140,5 @@ fun TextSecretDetailsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -16,7 +15,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -33,6 +31,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
|
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold
|
||||||
import com.github.nullptroma.wallenc.ui.resources.resolveText
|
import com.github.nullptroma.wallenc.ui.resources.resolveText
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -58,15 +58,10 @@ fun TextSecretEditScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
WallencScreenScaffold(modifier = modifier) { innerPadding ->
|
||||||
modifier = modifier,
|
WallencScreenContentPadding(innerPadding) {
|
||||||
contentWindowInsets = WindowInsets(0.dp),
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -156,4 +151,5 @@ fun TextSecretEditScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
|
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -10,7 +9,6 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -21,6 +19,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
|
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TextSecretsScreen(
|
fun TextSecretsScreen(
|
||||||
@@ -31,9 +31,8 @@ fun TextSecretsScreen(
|
|||||||
) {
|
) {
|
||||||
val uiState by viewModel.state.collectAsStateWithLifecycle()
|
val uiState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
WallencScreenScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentWindowInsets = WindowInsets(0.dp),
|
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -47,11 +46,10 @@ fun TextSecretsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
|
WallencScreenContentPadding(innerPadding) {
|
||||||
TextSecretsScreenContent(
|
TextSecretsScreenContent(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
) {
|
) {
|
||||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
items(uiState.items) { secret ->
|
items(uiState.items) { secret ->
|
||||||
@@ -64,4 +62,5 @@ fun TextSecretsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.withFrameMillis
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
@@ -79,9 +80,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
|
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
import com.github.nullptroma.wallenc.ui.elements.QrScannerDialog
|
import com.github.nullptroma.wallenc.ui.elements.QrScannerDialog
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold
|
||||||
import com.github.nullptroma.wallenc.usecases.TwoFaCodeState
|
import com.github.nullptroma.wallenc.usecases.TwoFaCodeState
|
||||||
import com.github.nullptroma.wallenc.usecases.buildTwoFaCodeState
|
import com.github.nullptroma.wallenc.usecases.buildTwoFaCodeState
|
||||||
import kotlinx.coroutines.delay
|
import com.github.nullptroma.wallenc.usecases.totpPeriodProgress
|
||||||
|
import com.github.nullptroma.wallenc.usecases.totpSecondsUntilRefresh
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -96,16 +100,16 @@ fun TwoFaTokensScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val nowMillis by produceState(initialValue = System.currentTimeMillis()) {
|
val nowMillis by produceState(initialValue = System.currentTimeMillis()) {
|
||||||
while (true) {
|
while (true) {
|
||||||
value = System.currentTimeMillis()
|
withFrameMillis { frameTimeMillis ->
|
||||||
delay(1000)
|
value = frameTimeMillis
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var editingToken by remember { mutableStateOf<TwoFaTokenRecord?>(null) }
|
var editingToken by remember { mutableStateOf<TwoFaTokenRecord?>(null) }
|
||||||
var creating by remember { mutableStateOf(false) }
|
var creating by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
WallencScreenScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentWindowInsets = WindowInsets(0.dp),
|
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -119,11 +123,10 @@ fun TwoFaTokensScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
|
WallencScreenContentPadding(innerPadding) {
|
||||||
TwoFaTokensScreenContent(
|
TwoFaTokensScreenContent(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
) {
|
) {
|
||||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
items(uiState.items) { item ->
|
items(uiState.items) { item ->
|
||||||
@@ -160,12 +163,13 @@ fun TwoFaTokensScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val codeProgress = if (codeState == null) 0f else {
|
val codeProgress = if (codeState == null) {
|
||||||
val period = item.periodSeconds.coerceAtLeast(1)
|
0f
|
||||||
val elapsed = (period - codeState.secondsUntilRefresh)
|
} else {
|
||||||
.coerceAtLeast(0)
|
totpPeriodProgress(nowMillis, item.periodSeconds)
|
||||||
.coerceAtMost(period)
|
}
|
||||||
elapsed.toFloat() / period.toFloat()
|
val secondsUntilRefresh = codeState?.let {
|
||||||
|
totpSecondsUntilRefresh(nowMillis, item.periodSeconds)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -183,10 +187,10 @@ fun TwoFaTokensScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (codeState != null) {
|
text = if (secondsUntilRefresh != null) {
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.two_fa_code_refresh_seconds,
|
R.string.two_fa_code_refresh_seconds,
|
||||||
codeState.secondsUntilRefresh,
|
secondsUntilRefresh,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.two_fa_code_invalid_secret)
|
stringResource(R.string.two_fa_code_invalid_secret)
|
||||||
@@ -246,6 +250,7 @@ fun TwoFaTokensScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (creating) {
|
if (creating) {
|
||||||
TwoFaTokenEditDialog(
|
TwoFaTokenEditDialog(
|
||||||
@@ -561,7 +566,9 @@ private fun TwoFaTokenEditDialog(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberTwoFaCode(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? {
|
private fun rememberTwoFaCode(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? {
|
||||||
return remember(token, nowMillis) {
|
val period = token.periodSeconds.coerceAtLeast(1)
|
||||||
|
val periodSlot = nowMillis / 1000L / period
|
||||||
|
return remember(token, periodSlot) {
|
||||||
buildTwoFaCodeState(token, nowMillis)
|
buildTwoFaCodeState(token, nowMillis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ fun TwoFaTokensScreenContent(
|
|||||||
tokenList: @Composable () -> Unit = {},
|
tokenList: @Composable () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
|
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.Tree
|
import com.github.nullptroma.wallenc.domain.datatypes.Tree
|
||||||
import com.github.nullptroma.wallenc.domain.errors.toWallencException
|
import com.github.nullptroma.wallenc.domain.errors.toWallencException
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
|
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
|
||||||
@@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -39,6 +41,8 @@ import java.util.UUID
|
|||||||
*/
|
*/
|
||||||
abstract class AbstractVaultBrowserViewModel(
|
abstract class AbstractVaultBrowserViewModel(
|
||||||
storagesFlow: Flow<List<IStorage>>,
|
storagesFlow: Flow<List<IStorage>>,
|
||||||
|
private val storagesScanInProgressFlow: Flow<Boolean> = flowOf(false),
|
||||||
|
private val vaultHeaderFlow: Flow<VaultBrowserHeader?> = flowOf(null),
|
||||||
private val vaultAvailabilityFlow: Flow<Boolean>,
|
private val vaultAvailabilityFlow: Flow<Boolean>,
|
||||||
private val resolveCreateVaultUuid: () -> UUID?,
|
private val resolveCreateVaultUuid: () -> UUID?,
|
||||||
private val removeStorageUseCase: RemoveStorageUseCase,
|
private val removeStorageUseCase: RemoveStorageUseCase,
|
||||||
@@ -63,8 +67,12 @@ abstract class AbstractVaultBrowserViewModel(
|
|||||||
private val _userNotifications = MutableSharedFlow<UserNotification>(extraBufferCapacity = 8)
|
private val _userNotifications = MutableSharedFlow<UserNotification>(extraBufferCapacity = 8)
|
||||||
val userNotifications: SharedFlow<UserNotification> = _userNotifications
|
val userNotifications: SharedFlow<UserNotification> = _userNotifications
|
||||||
|
|
||||||
|
/** Удалённый vault: показать кнопку повторного сканирования storages на Диске. */
|
||||||
|
open val supportsStorageRescan: Boolean = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
collectStoragesFlow(storagesFlow)
|
collectStoragesFlow(storagesFlow)
|
||||||
|
collectVaultHeaderFlow()
|
||||||
collectPipelineBusyFlags()
|
collectPipelineBusyFlags()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
vaultAvailabilityFlow
|
vaultAvailabilityFlow
|
||||||
@@ -94,13 +102,23 @@ abstract class AbstractVaultBrowserViewModel(
|
|||||||
t.locksVaultStorageList && isPipelineTaskActive(t.state)
|
t.locksVaultStorageList && isPipelineTaskActive(t.state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun collectVaultHeaderFlow() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
vaultHeaderFlow.collect { header ->
|
||||||
|
updateState(state.value.copy(header = header))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun collectStoragesFlow(storagesFlow: Flow<List<IStorage>>) {
|
private fun collectStoragesFlow(storagesFlow: Flow<List<IStorage>>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
combine(
|
combine(
|
||||||
storagesFlow,
|
storagesFlow,
|
||||||
|
storagesScanInProgressFlow,
|
||||||
getOpenedStoragesUseCase.openedStorages,
|
getOpenedStoragesUseCase.openedStorages,
|
||||||
) { storages, opened -> storages to opened }
|
) { storages, scanInProgress, opened ->
|
||||||
.collect { (storages, opened) ->
|
Triple(storages, scanInProgress, opened)
|
||||||
|
}.collect { (storages, scanInProgress, opened) ->
|
||||||
val list = mutableListOf<Tree<IStorageInfo>>()
|
val list = mutableListOf<Tree<IStorageInfo>>()
|
||||||
for (storage in storages) {
|
for (storage in storages) {
|
||||||
var tree = Tree<IStorageInfo>(storage)
|
var tree = Tree<IStorageInfo>(storage)
|
||||||
@@ -115,7 +133,7 @@ abstract class AbstractVaultBrowserViewModel(
|
|||||||
updateState(
|
updateState(
|
||||||
state.value.copy(
|
state.value.copy(
|
||||||
storagesList = list,
|
storagesList = list,
|
||||||
storagesRefreshing = false,
|
storagesRefreshing = scanInProgress,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -149,6 +167,35 @@ abstract class AbstractVaultBrowserViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun rescanStorages() {
|
||||||
|
if (!supportsStorageRescan) return
|
||||||
|
if (state.value.storagesRefreshing) {
|
||||||
|
notifyUser(R.string.vault_msg_rescan_already_in_progress)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isVaultListMutationActive()) {
|
||||||
|
notifyUser(R.string.vault_msg_vault_list_mutation_busy)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val vaultUuid = resolveCreateVaultUuid() ?: return
|
||||||
|
taskOrchestrator.enqueue(
|
||||||
|
title = uiStrings(R.string.task_title_rescan_vault_storages),
|
||||||
|
dispatcher = Dispatchers.IO,
|
||||||
|
locksVaultStorageList = true,
|
||||||
|
work = { ctx ->
|
||||||
|
try {
|
||||||
|
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.RescanVaultStorages))
|
||||||
|
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_rescanning_vault_storages))
|
||||||
|
manageVaultUseCase.rescanStorages(vaultUuid)
|
||||||
|
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_rescan_vault_storages_done))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.debug(TAG, "rescanStorages failed: ${e.stackTraceToString()}")
|
||||||
|
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_rescan_vault_storages_failed))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun createStorage() {
|
fun createStorage() {
|
||||||
if (!state.value.addStorageFabEnabled) {
|
if (!state.value.addStorageFabEnabled) {
|
||||||
logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)")
|
logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)")
|
||||||
@@ -207,9 +254,24 @@ abstract class AbstractVaultBrowserViewModel(
|
|||||||
ManageStoragesEncryptionUseCase.CanEncryptResult.Allowed -> {
|
ManageStoragesEncryptionUseCase.CanEncryptResult.Allowed -> {
|
||||||
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_encrypting))
|
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_encrypting))
|
||||||
manageStoragesEncryptionUseCase.enableEncryption(storage, key, encryptPath)
|
manageStoragesEncryptionUseCase.enableEncryption(storage, key, encryptPath)
|
||||||
manageStoragesEncryptionUseCase.openStorage(storage, key, rememberPassword)
|
if (rememberPassword) {
|
||||||
|
manageStoragesEncryptionUseCase.rememberStorageKey(storage, key)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
manageStoragesEncryptionUseCase.openStorage(
|
||||||
|
storage,
|
||||||
|
key,
|
||||||
|
rememberPassword = false,
|
||||||
|
)
|
||||||
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_encryption_enabled))
|
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_encryption_enabled))
|
||||||
_userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_enabled))
|
_userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_enabled))
|
||||||
|
} catch (openError: Exception) {
|
||||||
|
logger.debug(TAG, "open after encrypt failed: ${openError.stackTraceToString()}")
|
||||||
|
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_encryption_enabled))
|
||||||
|
_userNotifications.emit(
|
||||||
|
UserNotification.TextRes(R.string.msg_encryption_enabled_open_failed),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> {
|
ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> {
|
||||||
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_already_encrypted))
|
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_already_encrypted))
|
||||||
@@ -364,6 +426,9 @@ abstract class AbstractVaultBrowserViewModel(
|
|||||||
|
|
||||||
@StringRes
|
@StringRes
|
||||||
fun getStorageStatusRes(storage: IStorageInfo): Int {
|
fun getStorageStatusRes(storage: IStorageInfo): Int {
|
||||||
|
if (storage.metaLoadState.value == StorageMetaLoadState.Unavailable) {
|
||||||
|
return R.string.storage_status_meta_unavailable
|
||||||
|
}
|
||||||
val encrypted = storage.metaInfo.value.encInfo != null
|
val encrypted = storage.metaInfo.value.encInfo != null
|
||||||
if (!encrypted) return R.string.storage_status_not_encrypted
|
if (!encrypted) return R.string.storage_status_not_encrypted
|
||||||
val opened = isEncryptionSessionOpen(storage)
|
val opened = isEncryptionSessionOpen(storage)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
||||||
|
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
|
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IVault
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
|
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
|
||||||
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
||||||
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
|
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
|
||||||
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
|
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
|
||||||
@@ -10,6 +12,8 @@ import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
|
|||||||
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
|
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
|
||||||
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
|
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
|
||||||
import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase
|
import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase
|
||||||
|
import com.github.nullptroma.wallenc.vault.contract.DescribedVault
|
||||||
|
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
|
||||||
import com.github.nullptroma.wallenc.vault.contract.described
|
import com.github.nullptroma.wallenc.vault.contract.described
|
||||||
import com.github.nullptroma.wallenc.vault.contract.locals
|
import com.github.nullptroma.wallenc.vault.contract.locals
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -36,6 +40,8 @@ class LocalVaultViewModel @Inject constructor(
|
|||||||
storagesFlow = vaultsManager.vaults
|
storagesFlow = vaultsManager.vaults
|
||||||
.map { vaults -> vaults.described().locals.firstOrNull() }
|
.map { vaults -> vaults.described().locals.firstOrNull() }
|
||||||
.flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) },
|
.flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) },
|
||||||
|
vaultHeaderFlow = vaultsManager.vaults
|
||||||
|
.map { vaults -> vaults.described().locals.firstOrNull().toLocalVaultBrowserHeader() },
|
||||||
vaultAvailabilityFlow = vaultsManager.vaults
|
vaultAvailabilityFlow = vaultsManager.vaults
|
||||||
.map { vaults -> vaults.described().locals.firstOrNull() }
|
.map { vaults -> vaults.described().locals.firstOrNull() }
|
||||||
.flatMapLatest { v -> v?.isAvailable ?: flowOf(false) },
|
.flatMapLatest { v -> v?.isAvailable ?: flowOf(false) },
|
||||||
@@ -50,3 +56,8 @@ class LocalVaultViewModel @Inject constructor(
|
|||||||
uiStrings = uiStrings,
|
uiStrings = uiStrings,
|
||||||
logger = logger,
|
logger = logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun IVault?.toLocalVaultBrowserHeader(): VaultBrowserHeader? {
|
||||||
|
if ((this as? DescribedVault)?.descriptor !is VaultDescriptor.LocalDevice) return null
|
||||||
|
return VaultBrowserHeader(titleResId = R.string.screen_title_local_vault)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
|||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
|
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IVault
|
||||||
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
||||||
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
|
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
|
||||||
|
import com.github.nullptroma.wallenc.vault.contract.CloudBrand
|
||||||
|
import com.github.nullptroma.wallenc.vault.contract.DescribedVault
|
||||||
|
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
|
||||||
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
|
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
|
||||||
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
|
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
|
||||||
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
|
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
|
||||||
@@ -14,6 +19,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -32,6 +38,11 @@ class RemoteVaultViewModel @Inject constructor(
|
|||||||
logger: ILogger,
|
logger: ILogger,
|
||||||
) : AbstractVaultBrowserViewModel(
|
) : AbstractVaultBrowserViewModel(
|
||||||
storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()),
|
storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()),
|
||||||
|
storagesScanInProgressFlow = manageVaultUseCase.storagesScanInProgressOf(
|
||||||
|
savedStateHandle.requireVaultUuid(),
|
||||||
|
),
|
||||||
|
vaultHeaderFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid())
|
||||||
|
.map { vault -> vault.toRemoteVaultBrowserHeader() },
|
||||||
vaultAvailabilityFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid())
|
vaultAvailabilityFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid())
|
||||||
.flatMapLatest { v -> v?.isAvailable ?: flowOf(false) },
|
.flatMapLatest { v -> v?.isAvailable ?: flowOf(false) },
|
||||||
resolveCreateVaultUuid = { savedStateHandle.requireVaultUuid() },
|
resolveCreateVaultUuid = { savedStateHandle.requireVaultUuid() },
|
||||||
@@ -44,9 +55,25 @@ class RemoteVaultViewModel @Inject constructor(
|
|||||||
taskOrchestrator = taskOrchestrator,
|
taskOrchestrator = taskOrchestrator,
|
||||||
uiStrings = uiStrings,
|
uiStrings = uiStrings,
|
||||||
logger = logger,
|
logger = logger,
|
||||||
)
|
) {
|
||||||
|
override val supportsStorageRescan: Boolean = true
|
||||||
|
}
|
||||||
|
|
||||||
private fun SavedStateHandle.requireVaultUuid(): UUID {
|
private fun SavedStateHandle.requireVaultUuid(): UUID {
|
||||||
val raw = get<String>("vaultUuid") ?: error("Missing vault UUID in navigation arguments")
|
val raw = get<String>("vaultUuid") ?: error("Missing vault UUID in navigation arguments")
|
||||||
return UUID.fromString(raw)
|
return UUID.fromString(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun IVault?.toRemoteVaultBrowserHeader(): VaultBrowserHeader? {
|
||||||
|
val remote = (this as? DescribedVault)?.descriptor as? VaultDescriptor.LinkedRemote ?: return null
|
||||||
|
val subtitle = when (remote.brand) {
|
||||||
|
CloudBrand.YANDEX -> remote.accountDisplayName
|
||||||
|
}
|
||||||
|
val titleResId = when (remote.brand) {
|
||||||
|
CloudBrand.YANDEX -> R.string.screen_title_yandex_vault
|
||||||
|
}
|
||||||
|
return VaultBrowserHeader(
|
||||||
|
titleResId = titleResId,
|
||||||
|
subtitle = subtitle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
|
||||||
|
data class VaultBrowserHeader(
|
||||||
|
@param:StringRes val titleResId: Int,
|
||||||
|
val subtitle: String? = null,
|
||||||
|
)
|
||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -17,6 +18,7 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -31,7 +33,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -78,13 +79,13 @@ fun VaultBrowserScreen(
|
|||||||
val fabBusy = uiState.vaultListMutationActive
|
val fabBusy = uiState.vaultListMutationActive
|
||||||
val showFullscreenLoader = uiState.storagesList.isEmpty() && uiState.storagesRefreshing
|
val showFullscreenLoader = uiState.storagesList.isEmpty() && uiState.storagesRefreshing
|
||||||
val showEmptyState = uiState.storagesList.isEmpty() && !uiState.storagesRefreshing
|
val showEmptyState = uiState.storagesList.isEmpty() && !uiState.storagesRefreshing
|
||||||
|
val showRescan = viewModel.supportsStorageRescan
|
||||||
|
val rescanEnabled = showRescan &&
|
||||||
|
!uiState.vaultListMutationActive &&
|
||||||
|
!uiState.storagesRefreshing
|
||||||
val isUuidBusy: (UUID) -> Boolean = { uuid -> uuid in uiState.busyStorageUuids }
|
val isUuidBusy: (UUID) -> Boolean = { uuid -> uuid in uiState.busyStorageUuids }
|
||||||
|
|
||||||
Box {
|
val addFab: @Composable () -> Unit = {
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
contentWindowInsets = WindowInsets(0.dp),
|
|
||||||
floatingActionButton = {
|
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (fabEnabled && !fabBusy) {
|
if (fabEnabled && !fabBusy) {
|
||||||
@@ -104,13 +105,47 @@ fun VaultBrowserScreen(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
) { innerPadding ->
|
|
||||||
|
val vaultContent: @Composable (androidx.compose.foundation.layout.PaddingValues) -> Unit = { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
|
uiState.header?.let { header ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(header.titleResId),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
header.subtitle?.let { subtitle ->
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showRescan) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { viewModel.rescanStorages() },
|
||||||
|
enabled = rescanEnabled,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.vault_rescan_storages_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!fabEnabled) {
|
if (!fabEnabled) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.vault_unavailable_banner),
|
text = stringResource(R.string.vault_unavailable_banner),
|
||||||
@@ -122,25 +157,28 @@ fun VaultBrowserScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
showEmptyState -> {
|
showEmptyState -> {
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.vault_empty_list_hint),
|
text = stringResource(
|
||||||
|
if (showRescan) {
|
||||||
|
R.string.vault_empty_list_hint_remote
|
||||||
|
} else {
|
||||||
|
R.string.vault_empty_list_hint
|
||||||
|
},
|
||||||
|
),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -172,7 +210,9 @@ fun VaultBrowserScreen(
|
|||||||
onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) },
|
onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,22 +220,33 @@ fun VaultBrowserScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
Scaffold(
|
||||||
|
contentWindowInsets = WindowInsets(0.dp),
|
||||||
|
floatingActionButton = addFab,
|
||||||
|
content = vaultContent,
|
||||||
|
)
|
||||||
|
|
||||||
if (showFullscreenLoader) {
|
if (showFullscreenLoader) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Box(modifier = Modifier.fillMaxSize().alpha(0.6f).background(Color.Black))
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.scrim),
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(64.dp),
|
modifier = Modifier.size(64.dp),
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.vault_loading_storages),
|
text = stringResource(R.string.vault_loading_storages),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(horizontal = 24.dp),
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class VaultBrowserScreenState(
|
data class VaultBrowserScreenState(
|
||||||
|
val header: VaultBrowserHeader? = null,
|
||||||
val storagesList: List<Tree<IStorageInfo>>,
|
val storagesList: List<Tree<IStorageInfo>>,
|
||||||
/** Первый снимок списка storages ещё не получен (удалённый vault). */
|
/** Первый снимок списка storages ещё не получен (удалённый vault). */
|
||||||
val storagesRefreshing: Boolean,
|
val storagesRefreshing: Boolean,
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
package com.github.nullptroma.wallenc.ui.screens.shared
|
package com.github.nullptroma.wallenc.ui.screens.shared
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TextEditScreen(text: String) {
|
fun TextEditScreen(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
WallencScreenScaffold(modifier = modifier) { innerPadding ->
|
||||||
|
WallencScreenContentPadding(innerPadding) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.text_edit_screen_placeholder, text),
|
text = stringResource(R.string.text_edit_screen_placeholder, text),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.github.nullptroma.wallenc.ui.screens.sync
|
package com.github.nullptroma.wallenc.ui.screens.sync
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
@@ -39,6 +40,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -46,6 +48,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
|
import com.github.nullptroma.wallenc.ui.elements.AnimatedFloatingBackButton
|
||||||
import com.github.nullptroma.wallenc.ui.resources.UserNotification
|
import com.github.nullptroma.wallenc.ui.resources.UserNotification
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
|
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -441,28 +444,21 @@ private fun StoragePickerScreen(
|
|||||||
contentWindowInsets = WindowInsets(0.dp),
|
contentWindowInsets = WindowInsets(0.dp),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { inner ->
|
) { inner ->
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(inner)
|
.padding(inner)
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Rounded.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.sync_cd_picker_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.sync_picker_title, groupId),
|
text = stringResource(id = R.string.sync_picker_title, groupId),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -540,6 +536,15 @@ private fun StoragePickerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AnimatedFloatingBackButton(
|
||||||
|
visible = true,
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(start = 12.dp, bottom = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -44,13 +45,21 @@ class StorageSyncViewModel @Inject constructor(
|
|||||||
observeVaults()
|
observeVaults()
|
||||||
observeStorageSyncPipeline()
|
observeStorageSyncPipeline()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
vaultsManager.vaults
|
combine(
|
||||||
.flatMapLatest { vaults ->
|
vaultsManager.vaults,
|
||||||
if (vaults.isEmpty()) {
|
state.map { it.groups },
|
||||||
flowOf(false)
|
) { vaults, groups ->
|
||||||
|
val requiredUuids = groups.flatMap { it.storageUuids }.toSet()
|
||||||
|
if (requiredUuids.isEmpty() || vaults.isEmpty()) {
|
||||||
|
false
|
||||||
} else {
|
} else {
|
||||||
combine(vaults.map { it.storagesScanInProgress }) { flags ->
|
val opened = vaultsManager.unlockManager.openedStorages.value
|
||||||
flags.any { it }
|
vaults.any { vault ->
|
||||||
|
val uuidsInVault = vault.storages.value.flatMap { root ->
|
||||||
|
flattenStorages(buildStorageTree(root, opened))
|
||||||
|
}.map { it.uuid }.toSet()
|
||||||
|
uuidsInVault.any { it in requiredUuids } &&
|
||||||
|
vault.storagesScanInProgress.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,17 @@
|
|||||||
<string name="nav_label_main">Главная</string>
|
<string name="nav_label_main">Главная</string>
|
||||||
<string name="nav_label_sync">Синхронизация</string>
|
<string name="nav_label_sync">Синхронизация</string>
|
||||||
<string name="nav_label_settings">Настройки</string>
|
<string name="nav_label_settings">Настройки</string>
|
||||||
|
<string name="nav_cd_back">Назад</string>
|
||||||
|
<string name="screen_title_remote_vault">Удалённое хранилище</string>
|
||||||
|
<string name="screen_title_local_vault">Локальное хранилище</string>
|
||||||
|
<string name="screen_title_yandex_vault">Хранилище Яндекс.Диска</string>
|
||||||
|
<string name="screen_title_storage">Хранилище</string>
|
||||||
|
<string name="screen_title_two_fa">Токены 2FA</string>
|
||||||
|
<string name="screen_title_text_secrets">Текстовые секреты</string>
|
||||||
|
<string name="screen_title_text_edit">Текст</string>
|
||||||
<string name="main_work_status_label">Статус:</string>
|
<string name="main_work_status_label">Статус:</string>
|
||||||
<string name="main_status_multiple_tasks">Выполняется задач: %1$d</string>
|
<string name="main_status_multiple_tasks">Выполняется задач: %1$d</string>
|
||||||
<string name="main_status_vault_scanning_storages">Сканирование vault: загрузка списка хранилищ…</string>
|
<string name="main_status_vault_scanning_storages">Сканирование vault: загрузка списка хранилищ</string>
|
||||||
<string name="settings_title">Настройки</string>
|
<string name="settings_title">Настройки</string>
|
||||||
<string name="sync_groups_title">Группы синхронизации</string>
|
<string name="sync_groups_title">Группы синхронизации</string>
|
||||||
<string name="sync_progress_section_title">Синхронизация хранилищ</string>
|
<string name="sync_progress_section_title">Синхронизация хранилищ</string>
|
||||||
@@ -75,6 +83,9 @@
|
|||||||
<string name="storage_field_size">Размер: %1$s</string>
|
<string name="storage_field_size">Размер: %1$s</string>
|
||||||
<string name="storage_field_virtual">Виртуальное: %1$s</string>
|
<string name="storage_field_virtual">Виртуальное: %1$s</string>
|
||||||
<string name="storage_unavailable_hint">Хранилище недоступно</string>
|
<string name="storage_unavailable_hint">Хранилище недоступно</string>
|
||||||
|
<string name="storage_meta_unavailable_hint">Метаданные недоступны — переименование, шифрование и открытие отключены</string>
|
||||||
|
<string name="storage_status_meta_unavailable">Метаданные недоступны</string>
|
||||||
|
<string name="storage_home_meta_unavailable">Не удалось загрузить метаданные хранилища. 2FA и текстовые секреты недоступны.</string>
|
||||||
<string name="storage_menu_unavailable">Недоступно: %1$s</string>
|
<string name="storage_menu_unavailable">Недоступно: %1$s</string>
|
||||||
<string name="storage_status_not_encrypted">Не зашифровано</string>
|
<string name="storage_status_not_encrypted">Не зашифровано</string>
|
||||||
<string name="storage_status_encrypted_open">Зашифровано (открыто)</string>
|
<string name="storage_status_encrypted_open">Зашифровано (открыто)</string>
|
||||||
@@ -84,9 +95,12 @@
|
|||||||
<string name="vault_fab_add_storage_busy_cd">Создание хранилища уже выполняется</string>
|
<string name="vault_fab_add_storage_busy_cd">Создание хранилища уже выполняется</string>
|
||||||
<string name="vault_msg_storage_pipeline_busy">С этим хранилищем уже выполняется операция</string>
|
<string name="vault_msg_storage_pipeline_busy">С этим хранилищем уже выполняется операция</string>
|
||||||
<string name="vault_msg_vault_list_mutation_busy">Список хранилищ сейчас меняется — подождите</string>
|
<string name="vault_msg_vault_list_mutation_busy">Список хранилищ сейчас меняется — подождите</string>
|
||||||
|
<string name="vault_msg_rescan_already_in_progress">Сканирование хранилищ уже выполняется</string>
|
||||||
<string name="vault_unavailable_banner">Хранилище недоступно. Проверьте сеть, путь или разблокировку.</string>
|
<string name="vault_unavailable_banner">Хранилище недоступно. Проверьте сеть, путь или разблокировку.</string>
|
||||||
<string name="vault_loading_storages">Загрузка списка хранилищ…</string>
|
<string name="vault_loading_storages">Загрузка списка хранилищ…</string>
|
||||||
<string name="vault_empty_list_hint">В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно.</string>
|
<string name="vault_empty_list_hint">В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно.</string>
|
||||||
|
<string name="vault_empty_list_hint_remote">На удалённом хранилище каталоги не найдены. Если папки уже есть на сервере, нажмите «Обновить список», либо создайте хранилище кнопкой «+», когда оно доступно.</string>
|
||||||
|
<string name="vault_rescan_storages_action">Обновить список</string>
|
||||||
<string name="task_pipeline_title">Очередь задач</string>
|
<string name="task_pipeline_title">Очередь задач</string>
|
||||||
<string name="task_pipeline_jobs">Задачи</string>
|
<string name="task_pipeline_jobs">Задачи</string>
|
||||||
<string name="task_pipeline_log">Журнал</string>
|
<string name="task_pipeline_log">Журнал</string>
|
||||||
@@ -109,22 +123,23 @@
|
|||||||
<string name="task_title_create_storage">Создание хранилища</string>
|
<string name="task_title_create_storage">Создание хранилища</string>
|
||||||
<string name="task_title_enable_encryption">Включение шифрования</string>
|
<string name="task_title_enable_encryption">Включение шифрования</string>
|
||||||
<string name="task_title_open_encrypted_storage">Расшифровка и открытие хранилища</string>
|
<string name="task_title_open_encrypted_storage">Расшифровка и открытие хранилища</string>
|
||||||
<string name="task_progress_decrypt_running">Расшифровка…</string>
|
<string name="task_progress_decrypt_running">Расшифровка</string>
|
||||||
<string name="task_progress_dump_storage_log">Сканирование дерева…</string>
|
<string name="task_progress_dump_storage_log">Сканирование дерева</string>
|
||||||
<string name="task_progress_create_storage">Создание хранилища…</string>
|
<string name="task_progress_create_storage">Создание хранилища</string>
|
||||||
<string name="task_progress_enable_encryption">Шифрование…</string>
|
<string name="task_progress_enable_encryption">Шифрование</string>
|
||||||
<string name="task_progress_close_storage">Закрытие хранилища…</string>
|
<string name="task_progress_close_storage">Закрытие хранилища</string>
|
||||||
<string name="task_progress_disable_encryption">Очистка содержимого…</string>
|
<string name="task_progress_disable_encryption">Очистка содержимого</string>
|
||||||
<string name="task_progress_rename_storage">Переименование…</string>
|
<string name="task_progress_rename_storage">Переименование</string>
|
||||||
<string name="task_progress_remove_storage">Удаление…</string>
|
<string name="task_progress_remove_storage">Удаление</string>
|
||||||
<string name="task_progress_clear_sync_lock">Снятие блокировки…</string>
|
<string name="task_progress_clear_sync_lock">Снятие блокировки</string>
|
||||||
<string name="task_progress_add_remote_vault">Добавление…</string>
|
<string name="task_progress_add_remote_vault">Добавление</string>
|
||||||
<string name="task_progress_remove_remote_vault">Удаление…</string>
|
<string name="task_progress_remove_remote_vault">Удаление</string>
|
||||||
<string name="task_progress_retry_remote_vault">Подключение…</string>
|
<string name="task_progress_retry_remote_vault">Подключение</string>
|
||||||
<string name="task_progress_save_2fa_token">Сохранение…</string>
|
<string name="task_progress_rescan_vault_storages">Сканирование хранилищ</string>
|
||||||
<string name="task_progress_delete_2fa_token">Удаление…</string>
|
<string name="task_progress_save_2fa_token">Сохранение</string>
|
||||||
<string name="task_progress_save_text_secret">Сохранение…</string>
|
<string name="task_progress_delete_2fa_token">Удаление</string>
|
||||||
<string name="task_progress_delete_text_secret">Удаление…</string>
|
<string name="task_progress_save_text_secret">Сохранение</string>
|
||||||
|
<string name="task_progress_delete_text_secret">Удаление</string>
|
||||||
<string name="task_title_close_encrypted_storage">Закрытие зашифрованного хранилища</string>
|
<string name="task_title_close_encrypted_storage">Закрытие зашифрованного хранилища</string>
|
||||||
<string name="task_title_disable_encryption">Отключение шифрования</string>
|
<string name="task_title_disable_encryption">Отключение шифрования</string>
|
||||||
<string name="task_title_rename_storage">Переименование хранилища</string>
|
<string name="task_title_rename_storage">Переименование хранилища</string>
|
||||||
@@ -133,6 +148,7 @@
|
|||||||
<string name="task_title_add_remote_vault">Добавление удалённого хранилища</string>
|
<string name="task_title_add_remote_vault">Добавление удалённого хранилища</string>
|
||||||
<string name="task_title_remove_remote_vault">Удаление удалённого хранилища</string>
|
<string name="task_title_remove_remote_vault">Удаление удалённого хранилища</string>
|
||||||
<string name="task_title_retry_remote_vault">Повторное подключение удалённого хранилища</string>
|
<string name="task_title_retry_remote_vault">Повторное подключение удалённого хранилища</string>
|
||||||
|
<string name="task_title_rescan_vault_storages">Обновление списка хранилищ</string>
|
||||||
<string name="task_title_storage_sync">Синхронизация хранилищ</string>
|
<string name="task_title_storage_sync">Синхронизация хранилищ</string>
|
||||||
<string name="task_title_storage_sync_background">Фоновая синхронизация хранилищ</string>
|
<string name="task_title_storage_sync_background">Фоновая синхронизация хранилищ</string>
|
||||||
<string name="task_title_save_2fa_token">Сохранение 2FA токена</string>
|
<string name="task_title_save_2fa_token">Сохранение 2FA токена</string>
|
||||||
@@ -162,6 +178,7 @@
|
|||||||
<string name="vault_link_error_unknown">Не удалось войти</string>
|
<string name="vault_link_error_unknown">Не удалось войти</string>
|
||||||
<string name="vault_link_error_unsupported_brand">Этот провайдер не поддерживается</string>
|
<string name="vault_link_error_unsupported_brand">Этот провайдер не поддерживается</string>
|
||||||
<string name="msg_encryption_enabled">Шифрование включено</string>
|
<string name="msg_encryption_enabled">Шифрование включено</string>
|
||||||
|
<string name="msg_encryption_enabled_open_failed">Шифрование включено; откройте хранилище вручную для просмотра</string>
|
||||||
<string name="msg_storage_already_encrypted">Хранилище уже зашифровано</string>
|
<string name="msg_storage_already_encrypted">Хранилище уже зашифровано</string>
|
||||||
<string name="msg_storage_not_empty">Хранилище не пустое</string>
|
<string name="msg_storage_not_empty">Хранилище не пустое</string>
|
||||||
<string name="msg_storage_empty_state_unknown">Не удалось определить, пусто ли хранилище</string>
|
<string name="msg_storage_empty_state_unknown">Не удалось определить, пусто ли хранилище</string>
|
||||||
@@ -314,6 +331,9 @@
|
|||||||
<string name="task_log_retrying_vault">Повторное подключение…</string>
|
<string name="task_log_retrying_vault">Повторное подключение…</string>
|
||||||
<string name="task_log_retry_requested">Повтор запрошен</string>
|
<string name="task_log_retry_requested">Повтор запрошен</string>
|
||||||
<string name="task_log_retry_vault_failed">Не удалось повторить подключение</string>
|
<string name="task_log_retry_vault_failed">Не удалось повторить подключение</string>
|
||||||
|
<string name="task_log_rescanning_vault_storages">Повторное сканирование хранилищ на удалённом vault…</string>
|
||||||
|
<string name="task_log_rescan_vault_storages_done">Список хранилищ обновлён</string>
|
||||||
|
<string name="task_log_rescan_vault_storages_failed">Не удалось обновить список хранилищ</string>
|
||||||
<string name="task_log_test_started">Тестовая задача запущена на %1$d с</string>
|
<string name="task_log_test_started">Тестовая задача запущена на %1$d с</string>
|
||||||
<string name="task_log_test_finished">Тестовая задача завершена</string>
|
<string name="task_log_test_finished">Тестовая задача завершена</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -7,9 +7,17 @@
|
|||||||
<string name="nav_label_main">Home</string>
|
<string name="nav_label_main">Home</string>
|
||||||
<string name="nav_label_sync">Sync</string>
|
<string name="nav_label_sync">Sync</string>
|
||||||
<string name="nav_label_settings">Settings</string>
|
<string name="nav_label_settings">Settings</string>
|
||||||
|
<string name="nav_cd_back">Go back</string>
|
||||||
|
<string name="screen_title_remote_vault">Remote vault</string>
|
||||||
|
<string name="screen_title_local_vault">Local vault</string>
|
||||||
|
<string name="screen_title_yandex_vault">Yandex Disk vault</string>
|
||||||
|
<string name="screen_title_storage">Storage</string>
|
||||||
|
<string name="screen_title_two_fa">2FA tokens</string>
|
||||||
|
<string name="screen_title_text_secrets">Text secrets</string>
|
||||||
|
<string name="screen_title_text_edit">Text</string>
|
||||||
<string name="main_work_status_label">Status:</string>
|
<string name="main_work_status_label">Status:</string>
|
||||||
<string name="main_status_multiple_tasks">Running tasks: %1$d</string>
|
<string name="main_status_multiple_tasks">Running tasks: %1$d</string>
|
||||||
<string name="main_status_vault_scanning_storages">Scanning vault: loading storage list…</string>
|
<string name="main_status_vault_scanning_storages">Scanning vault: loading storage list</string>
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
<string name="sync_groups_title">Sync groups</string>
|
<string name="sync_groups_title">Sync groups</string>
|
||||||
<string name="sync_progress_section_title">Storage sync</string>
|
<string name="sync_progress_section_title">Storage sync</string>
|
||||||
@@ -75,6 +83,9 @@
|
|||||||
<string name="storage_field_size">Size: %1$s</string>
|
<string name="storage_field_size">Size: %1$s</string>
|
||||||
<string name="storage_field_virtual">Virtual: %1$s</string>
|
<string name="storage_field_virtual">Virtual: %1$s</string>
|
||||||
<string name="storage_unavailable_hint">Storage unavailable</string>
|
<string name="storage_unavailable_hint">Storage unavailable</string>
|
||||||
|
<string name="storage_meta_unavailable_hint">Metadata unavailable — rename, encryption, and open are disabled</string>
|
||||||
|
<string name="storage_status_meta_unavailable">Metadata unavailable</string>
|
||||||
|
<string name="storage_home_meta_unavailable">Storage metadata could not be loaded. 2FA and text secrets are unavailable.</string>
|
||||||
<string name="storage_menu_unavailable">Unavailable: %1$s</string>
|
<string name="storage_menu_unavailable">Unavailable: %1$s</string>
|
||||||
<string name="storage_status_not_encrypted">Not encrypted</string>
|
<string name="storage_status_not_encrypted">Not encrypted</string>
|
||||||
<string name="storage_status_encrypted_open">Encrypted (open)</string>
|
<string name="storage_status_encrypted_open">Encrypted (open)</string>
|
||||||
@@ -84,9 +95,12 @@
|
|||||||
<string name="vault_fab_add_storage_busy_cd">Storage creation already running</string>
|
<string name="vault_fab_add_storage_busy_cd">Storage creation already running</string>
|
||||||
<string name="vault_msg_storage_pipeline_busy">An operation is already running for this storage</string>
|
<string name="vault_msg_storage_pipeline_busy">An operation is already running for this storage</string>
|
||||||
<string name="vault_msg_vault_list_mutation_busy">Storage list is changing — please wait</string>
|
<string name="vault_msg_vault_list_mutation_busy">Storage list is changing — please wait</string>
|
||||||
|
<string name="vault_msg_rescan_already_in_progress">Storage scan is already in progress</string>
|
||||||
<string name="vault_unavailable_banner">Vault unavailable. Check network, path, or unlock.</string>
|
<string name="vault_unavailable_banner">Vault unavailable. Check network, path, or unlock.</string>
|
||||||
<string name="vault_loading_storages">Loading storage list…</string>
|
<string name="vault_loading_storages">Loading storage list…</string>
|
||||||
<string name="vault_empty_list_hint">No folders yet. Create storage with "+" when available.</string>
|
<string name="vault_empty_list_hint">No folders yet. Create storage with "+" when available.</string>
|
||||||
|
<string name="vault_empty_list_hint_remote">No storages found on the remote vault. Tap rescan if folders already exist on the server, or create one with "+" when available.</string>
|
||||||
|
<string name="vault_rescan_storages_action">Rescan storages</string>
|
||||||
<string name="task_pipeline_title">Task queue</string>
|
<string name="task_pipeline_title">Task queue</string>
|
||||||
<string name="task_pipeline_jobs">Tasks</string>
|
<string name="task_pipeline_jobs">Tasks</string>
|
||||||
<string name="task_pipeline_log">Log</string>
|
<string name="task_pipeline_log">Log</string>
|
||||||
@@ -109,22 +123,23 @@
|
|||||||
<string name="task_title_create_storage">Create storage</string>
|
<string name="task_title_create_storage">Create storage</string>
|
||||||
<string name="task_title_enable_encryption">Enable encryption</string>
|
<string name="task_title_enable_encryption">Enable encryption</string>
|
||||||
<string name="task_title_open_encrypted_storage">Decrypt and open storage</string>
|
<string name="task_title_open_encrypted_storage">Decrypt and open storage</string>
|
||||||
<string name="task_progress_decrypt_running">Decrypting…</string>
|
<string name="task_progress_decrypt_running">Decrypting</string>
|
||||||
<string name="task_progress_dump_storage_log">Scanning tree…</string>
|
<string name="task_progress_dump_storage_log">Scanning tree</string>
|
||||||
<string name="task_progress_create_storage">Creating storage…</string>
|
<string name="task_progress_create_storage">Creating storage</string>
|
||||||
<string name="task_progress_enable_encryption">Encrypting…</string>
|
<string name="task_progress_enable_encryption">Encrypting</string>
|
||||||
<string name="task_progress_close_storage">Closing storage…</string>
|
<string name="task_progress_close_storage">Closing storage</string>
|
||||||
<string name="task_progress_disable_encryption">Clearing content…</string>
|
<string name="task_progress_disable_encryption">Clearing content</string>
|
||||||
<string name="task_progress_rename_storage">Renaming…</string>
|
<string name="task_progress_rename_storage">Renaming</string>
|
||||||
<string name="task_progress_remove_storage">Removing…</string>
|
<string name="task_progress_remove_storage">Removing</string>
|
||||||
<string name="task_progress_clear_sync_lock">Clearing sync lock…</string>
|
<string name="task_progress_clear_sync_lock">Clearing sync lock</string>
|
||||||
<string name="task_progress_add_remote_vault">Adding…</string>
|
<string name="task_progress_add_remote_vault">Adding</string>
|
||||||
<string name="task_progress_remove_remote_vault">Removing…</string>
|
<string name="task_progress_remove_remote_vault">Removing</string>
|
||||||
<string name="task_progress_retry_remote_vault">Connecting…</string>
|
<string name="task_progress_retry_remote_vault">Connecting</string>
|
||||||
<string name="task_progress_save_2fa_token">Saving…</string>
|
<string name="task_progress_rescan_vault_storages">Scanning storages</string>
|
||||||
<string name="task_progress_delete_2fa_token">Removing…</string>
|
<string name="task_progress_save_2fa_token">Saving</string>
|
||||||
<string name="task_progress_save_text_secret">Saving…</string>
|
<string name="task_progress_delete_2fa_token">Removing</string>
|
||||||
<string name="task_progress_delete_text_secret">Removing…</string>
|
<string name="task_progress_save_text_secret">Saving</string>
|
||||||
|
<string name="task_progress_delete_text_secret">Removing</string>
|
||||||
<string name="task_title_close_encrypted_storage">Close encrypted storage</string>
|
<string name="task_title_close_encrypted_storage">Close encrypted storage</string>
|
||||||
<string name="task_title_disable_encryption">Disable encryption</string>
|
<string name="task_title_disable_encryption">Disable encryption</string>
|
||||||
<string name="task_title_rename_storage">Rename storage</string>
|
<string name="task_title_rename_storage">Rename storage</string>
|
||||||
@@ -133,6 +148,7 @@
|
|||||||
<string name="task_title_add_remote_vault">Add remote vault</string>
|
<string name="task_title_add_remote_vault">Add remote vault</string>
|
||||||
<string name="task_title_remove_remote_vault">Remove remote vault</string>
|
<string name="task_title_remove_remote_vault">Remove remote vault</string>
|
||||||
<string name="task_title_retry_remote_vault">Retry remote vault connection</string>
|
<string name="task_title_retry_remote_vault">Retry remote vault connection</string>
|
||||||
|
<string name="task_title_rescan_vault_storages">Rescan vault storages</string>
|
||||||
<string name="task_title_storage_sync">Storage sync</string>
|
<string name="task_title_storage_sync">Storage sync</string>
|
||||||
<string name="task_title_storage_sync_background">Background storage sync</string>
|
<string name="task_title_storage_sync_background">Background storage sync</string>
|
||||||
<string name="task_title_save_2fa_token">Save 2FA token</string>
|
<string name="task_title_save_2fa_token">Save 2FA token</string>
|
||||||
@@ -162,6 +178,7 @@
|
|||||||
<string name="vault_link_error_unknown">Sign-in failed</string>
|
<string name="vault_link_error_unknown">Sign-in failed</string>
|
||||||
<string name="vault_link_error_unsupported_brand">This provider is not supported</string>
|
<string name="vault_link_error_unsupported_brand">This provider is not supported</string>
|
||||||
<string name="msg_encryption_enabled">Encryption enabled</string>
|
<string name="msg_encryption_enabled">Encryption enabled</string>
|
||||||
|
<string name="msg_encryption_enabled_open_failed">Encryption enabled; unlock the storage manually to view contents</string>
|
||||||
<string name="msg_storage_already_encrypted">Storage is already encrypted</string>
|
<string name="msg_storage_already_encrypted">Storage is already encrypted</string>
|
||||||
<string name="msg_storage_not_empty">Storage is not empty</string>
|
<string name="msg_storage_not_empty">Storage is not empty</string>
|
||||||
<string name="msg_storage_empty_state_unknown">Could not determine if storage is empty</string>
|
<string name="msg_storage_empty_state_unknown">Could not determine if storage is empty</string>
|
||||||
@@ -314,6 +331,9 @@
|
|||||||
<string name="task_log_retrying_vault">Retrying remote vault connection…</string>
|
<string name="task_log_retrying_vault">Retrying remote vault connection…</string>
|
||||||
<string name="task_log_retry_requested">Retry requested</string>
|
<string name="task_log_retry_requested">Retry requested</string>
|
||||||
<string name="task_log_retry_vault_failed">Failed to retry remote vault</string>
|
<string name="task_log_retry_vault_failed">Failed to retry remote vault</string>
|
||||||
|
<string name="task_log_rescanning_vault_storages">Rescanning storages on remote vault…</string>
|
||||||
|
<string name="task_log_rescan_vault_storages_done">Storage list updated</string>
|
||||||
|
<string name="task_log_rescan_vault_storages_failed">Failed to rescan storages</string>
|
||||||
<string name="task_log_test_started">Test task started for %1$d s</string>
|
<string name="task_log_test_started">Test task started for %1$d s</string>
|
||||||
<string name="task_log_test_finished">Test task finished</string>
|
<string name="task_log_test_finished">Test task finished</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ class ManageStoragesEncryptionUseCase @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun rememberStorageKey(storage: IStorageInfo, key: EncryptKey) {
|
||||||
|
if (storage is IStorage) {
|
||||||
|
unlockManager.rememberKey(storage, key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw IllegalStateException("Unsupported storage type")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun openStorage(storage: IStorageInfo, key: EncryptKey, rememberPassword: Boolean): IStorageInfo {
|
suspend fun openStorage(storage: IStorageInfo, key: EncryptKey, rememberPassword: Boolean): IStorageInfo {
|
||||||
if (storage is IStorage) return unlockManager.open(storage, key, rememberPassword)
|
if (storage is IStorage) return unlockManager.open(storage, key, rememberPassword)
|
||||||
throw IllegalStateException("Unsupported storage type")
|
throw IllegalStateException("Unsupported storage type")
|
||||||
|
|||||||
@@ -30,10 +30,21 @@ class ManageVaultUseCase @Inject constructor(
|
|||||||
fun storagesOf(vaultUuid: UUID): Flow<List<IStorage>> =
|
fun storagesOf(vaultUuid: UUID): Flow<List<IStorage>> =
|
||||||
observe(vaultUuid).flatMapLatest { vault -> vault?.storages ?: flowOf(emptyList()) }
|
observe(vaultUuid).flatMapLatest { vault -> vault?.storages ?: flowOf(emptyList()) }
|
||||||
|
|
||||||
|
/** Идёт листинг/пересканирование storages vault'а. */
|
||||||
|
fun storagesScanInProgressOf(vaultUuid: UUID): Flow<Boolean> =
|
||||||
|
observe(vaultUuid).flatMapLatest { vault -> vault?.storagesScanInProgress ?: flowOf(false) }
|
||||||
|
|
||||||
/** Создать новое хранилище в указанном vault'е. */
|
/** Создать новое хранилище в указанном vault'е. */
|
||||||
suspend fun createStorage(vaultUuid: UUID): IStorage {
|
suspend fun createStorage(vaultUuid: UUID): IStorage {
|
||||||
val vault = find(vaultUuid)
|
val vault = find(vaultUuid)
|
||||||
?: throw IllegalStateException("Vault $vaultUuid is not registered")
|
?: throw IllegalStateException("Vault $vaultUuid is not registered")
|
||||||
return vault.createStorage()
|
return vault.createStorage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Пересканировать storages vault'а (листинг на Диске и повторный init). */
|
||||||
|
suspend fun rescanStorages(vaultUuid: UUID) {
|
||||||
|
val vault = find(vaultUuid)
|
||||||
|
?: throw IllegalStateException("Vault $vaultUuid is not registered")
|
||||||
|
vault.rescanStorages()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,21 @@ data class TwoFaCodeState(
|
|||||||
val secondsUntilRefresh: Int,
|
val secondsUntilRefresh: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Доля прошедшего TOTP-периода [0f, 1f] для плавного progress bar. */
|
||||||
|
fun totpPeriodProgress(nowMillis: Long, periodSeconds: Int): Float {
|
||||||
|
val period = periodSeconds.coerceAtLeast(1)
|
||||||
|
val elapsedSec = (nowMillis / 1000.0) % period
|
||||||
|
return (elapsedSec / period).toFloat().coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun totpSecondsUntilRefresh(nowMillis: Long, periodSeconds: Int): Int {
|
||||||
|
val period = periodSeconds.coerceAtLeast(1)
|
||||||
|
return (period - ((nowMillis / 1000L) % period)).toInt().coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
fun buildTwoFaCodeState(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? {
|
fun buildTwoFaCodeState(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? {
|
||||||
val period = token.periodSeconds.coerceAtLeast(1)
|
val period = token.periodSeconds.coerceAtLeast(1)
|
||||||
val remaining = (period - ((nowMillis / 1000L) % period)).toInt().coerceAtLeast(0)
|
val remaining = totpSecondsUntilRefresh(nowMillis, period)
|
||||||
val code = generateTotpCode(
|
val code = generateTotpCode(
|
||||||
secret = token.secret,
|
secret = token.secret,
|
||||||
nowMillis = nowMillis,
|
nowMillis = nowMillis,
|
||||||
|
|||||||
@@ -33,6 +33,24 @@ class TwoFaTotpTest {
|
|||||||
assertTrue(state.secondsUntilRefresh in 1..30)
|
assertTrue(state.secondsUntilRefresh in 1..30)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun totpPeriodProgressIsContinuousWithinPeriod() {
|
||||||
|
val period = 30
|
||||||
|
val periodStartMillis = 1_700_000_100_000L // epoch sec кратна period
|
||||||
|
assertEquals(0f, totpPeriodProgress(periodStartMillis, period), 0.001f)
|
||||||
|
assertEquals(0.5f, totpPeriodProgress(periodStartMillis + 15_000L, period), 0.001f)
|
||||||
|
assertEquals(29f / 30f, totpPeriodProgress(periodStartMillis + 29_000L, period), 0.001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun totpSecondsUntilRefreshCountsDownWithinPeriod() {
|
||||||
|
val period = 30
|
||||||
|
val t0 = 1_700_000_100_000L
|
||||||
|
assertEquals(30, totpSecondsUntilRefresh(t0, period))
|
||||||
|
assertEquals(15, totpSecondsUntilRefresh(t0 + 15_000L, period))
|
||||||
|
assertEquals(1, totpSecondsUntilRefresh(t0 + 29_000L, period))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildTwoFaCodeStateReturnsNullForInvalidSecret() {
|
fun buildTwoFaCodeStateReturnsNullForInvalidSecret() {
|
||||||
val token = TwoFaTokenRecord(
|
val token = TwoFaTokenRecord(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.github.nullptroma.wallenc.usecases.fakes
|
package com.github.nullptroma.wallenc.usecases.fakes
|
||||||
|
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
||||||
@@ -23,6 +24,8 @@ class FakeStorage(
|
|||||||
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
|
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
|
||||||
override val isEmpty: Flow<Boolean?> = flowOf(true)
|
override val isEmpty: Flow<Boolean?> = flowOf(true)
|
||||||
override val metaInfo: StateFlow<IStorageMetaInfo> = MutableStateFlow(meta)
|
override val metaInfo: StateFlow<IStorageMetaInfo> = MutableStateFlow(meta)
|
||||||
|
override val metaLoadState: StateFlow<StorageMetaLoadState> =
|
||||||
|
MutableStateFlow(StorageMetaLoadState.Ready)
|
||||||
override val isVirtualStorage: Boolean = false
|
override val isVirtualStorage: Boolean = false
|
||||||
override val accessor: IStorageAccessor = accessorImpl
|
override val accessor: IStorageAccessor = accessorImpl
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class FakeUnlockManager : IUnlockManager {
|
|||||||
|
|
||||||
override suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean): IStorage = storage
|
override suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean): IStorage = storage
|
||||||
|
|
||||||
|
override suspend fun rememberKey(storage: IStorage, key: EncryptKey) = Unit
|
||||||
|
|
||||||
override suspend fun close(storage: IStorage) = Unit
|
override suspend fun close(storage: IStorage) = Unit
|
||||||
|
|
||||||
override suspend fun close(uuid: UUID) = Unit
|
override suspend fun close(uuid: UUID) = Unit
|
||||||
|
|||||||
Reference in New Issue
Block a user