Benoit Marty
8 months ago
35 changed files with 1817 additions and 15 deletions
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
plugins { |
||||
id("io.element.android-library") |
||||
} |
||||
|
||||
android { |
||||
namespace = "io.element.android.features.viewfolder.api" |
||||
} |
||||
|
||||
dependencies { |
||||
implementation(projects.libraries.architecture) |
||||
} |
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.api |
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext |
||||
import com.bumble.appyx.core.node.Node |
||||
import com.bumble.appyx.core.plugin.Plugin |
||||
import io.element.android.libraries.architecture.FeatureEntryPoint |
||||
|
||||
interface ViewFolderEntryPoint : FeatureEntryPoint { |
||||
data class Params( |
||||
val rootPath: String, |
||||
) |
||||
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder |
||||
|
||||
interface NodeBuilder { |
||||
fun params(params: Params): NodeBuilder |
||||
fun callback(callback: Callback): NodeBuilder |
||||
fun build(): Node |
||||
} |
||||
|
||||
interface Callback : Plugin { |
||||
fun onDone() |
||||
} |
||||
} |
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
plugins { |
||||
id("io.element.android-compose-library") |
||||
alias(libs.plugins.anvil) |
||||
alias(libs.plugins.ksp) |
||||
id("kotlin-parcelize") |
||||
} |
||||
|
||||
android { |
||||
namespace = "io.element.android.features.viewfolder.impl" |
||||
} |
||||
|
||||
anvil { |
||||
generateDaggerFactories.set(true) |
||||
} |
||||
|
||||
dependencies { |
||||
implementation(projects.anvilannotations) |
||||
anvil(projects.anvilcodegen) |
||||
implementation(projects.libraries.androidutils) |
||||
implementation(projects.libraries.architecture) |
||||
implementation(projects.libraries.core) |
||||
implementation(projects.libraries.designsystem) |
||||
implementation(projects.libraries.uiStrings) |
||||
api(projects.features.viewfolder.api) |
||||
ksp(libs.showkase.processor) |
||||
|
||||
testImplementation(libs.test.junit) |
||||
testImplementation(libs.test.robolectric) |
||||
testImplementation(libs.coroutines.test) |
||||
testImplementation(libs.molecule.runtime) |
||||
testImplementation(libs.test.truth) |
||||
testImplementation(libs.test.turbine) |
||||
testImplementation(projects.tests.testutils) |
||||
} |
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl |
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext |
||||
import com.bumble.appyx.core.node.Node |
||||
import com.bumble.appyx.core.plugin.Plugin |
||||
import com.squareup.anvil.annotations.ContributesBinding |
||||
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint |
||||
import io.element.android.features.viewfolder.impl.root.ViewFolderRootNode |
||||
import io.element.android.libraries.architecture.createNode |
||||
import io.element.android.libraries.di.AppScope |
||||
import javax.inject.Inject |
||||
|
||||
@ContributesBinding(AppScope::class) |
||||
class DefaultViewFolderEntryPoint @Inject constructor() : ViewFolderEntryPoint { |
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): ViewFolderEntryPoint.NodeBuilder { |
||||
val plugins = ArrayList<Plugin>() |
||||
|
||||
return object : ViewFolderEntryPoint.NodeBuilder { |
||||
override fun params(params: ViewFolderEntryPoint.Params): ViewFolderEntryPoint.NodeBuilder { |
||||
plugins += ViewFolderRootNode.Inputs(params.rootPath) |
||||
return this |
||||
} |
||||
|
||||
override fun callback(callback: ViewFolderEntryPoint.Callback): ViewFolderEntryPoint.NodeBuilder { |
||||
plugins += callback |
||||
return this |
||||
} |
||||
|
||||
override fun build(): Node { |
||||
return parentNode.createNode<ViewFolderRootNode>(buildContext, plugins) |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.file |
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding |
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers |
||||
import io.element.android.libraries.di.AppScope |
||||
import kotlinx.coroutines.withContext |
||||
import java.io.File |
||||
import javax.inject.Inject |
||||
|
||||
interface FileContentReader { |
||||
suspend fun getLines(path: String): List<String> |
||||
} |
||||
|
||||
@ContributesBinding(AppScope::class) |
||||
class DefaultFileContentReader @Inject constructor( |
||||
private val dispatchers: CoroutineDispatchers, |
||||
) : FileContentReader { |
||||
override suspend fun getLines(path: String): List<String> = withContext(dispatchers.io) { |
||||
try { |
||||
File(path).readLines() |
||||
} catch (exception: Exception) { |
||||
buildList { |
||||
add("Error reading file $path") |
||||
add(exception.toString()) |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.file |
||||
|
||||
import android.content.ContentValues |
||||
import android.content.Context |
||||
import android.os.Build |
||||
import android.os.Environment |
||||
import android.provider.MediaStore |
||||
import androidx.annotation.RequiresApi |
||||
import com.squareup.anvil.annotations.ContributesBinding |
||||
import io.element.android.libraries.androidutils.system.toast |
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers |
||||
import io.element.android.libraries.core.mimetype.MimeTypes |
||||
import io.element.android.libraries.di.AppScope |
||||
import io.element.android.libraries.di.ApplicationContext |
||||
import kotlinx.coroutines.withContext |
||||
import timber.log.Timber |
||||
import java.io.File |
||||
import java.io.FileOutputStream |
||||
import javax.inject.Inject |
||||
|
||||
interface FileSave { |
||||
suspend fun save( |
||||
path: String, |
||||
) |
||||
} |
||||
|
||||
@ContributesBinding(AppScope::class) |
||||
class DefaultFileSave @Inject constructor( |
||||
@ApplicationContext private val context: Context, |
||||
private val dispatchers: CoroutineDispatchers, |
||||
) : FileSave { |
||||
override suspend fun save( |
||||
path: String, |
||||
) { |
||||
withContext(dispatchers.io) { |
||||
runCatching { |
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { |
||||
saveOnDiskUsingMediaStore(path) |
||||
} else { |
||||
saveOnDiskUsingExternalStorageApi(path) |
||||
} |
||||
}.onSuccess { |
||||
Timber.v("Save on disk succeed") |
||||
withContext(dispatchers.main) { |
||||
context.toast("Save on disk succeed") |
||||
} |
||||
}.onFailure { |
||||
Timber.e(it, "Save on disk failed") |
||||
} |
||||
} |
||||
} |
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q) |
||||
private fun saveOnDiskUsingMediaStore(path: String) { |
||||
val file = File(path) |
||||
val contentValues = ContentValues().apply { |
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, file.name) |
||||
put(MediaStore.MediaColumns.MIME_TYPE, MimeTypes.OctetStream) |
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) |
||||
} |
||||
val resolver = context.contentResolver |
||||
val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) |
||||
if (outputUri != null) { |
||||
file.inputStream().use { input -> |
||||
resolver.openOutputStream(outputUri).use { output -> |
||||
input.copyTo(output!!, DEFAULT_BUFFER_SIZE) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun saveOnDiskUsingExternalStorageApi(path: String) { |
||||
val file = File(path) |
||||
val target = File( |
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), |
||||
file.name |
||||
) |
||||
file.inputStream().use { input -> |
||||
FileOutputStream(target).use { output -> |
||||
input.copyTo(output) |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.file |
||||
|
||||
import android.content.Context |
||||
import android.content.Intent |
||||
import android.net.Uri |
||||
import androidx.core.content.FileProvider |
||||
import com.squareup.anvil.annotations.ContributesBinding |
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers |
||||
import io.element.android.libraries.core.meta.BuildMeta |
||||
import io.element.android.libraries.core.mimetype.MimeTypes |
||||
import io.element.android.libraries.di.AppScope |
||||
import io.element.android.libraries.di.ApplicationContext |
||||
import kotlinx.coroutines.withContext |
||||
import timber.log.Timber |
||||
import java.io.File |
||||
import javax.inject.Inject |
||||
|
||||
interface FileShare { |
||||
suspend fun share( |
||||
path: String |
||||
) |
||||
} |
||||
|
||||
@ContributesBinding(AppScope::class) |
||||
class DefaultFileShare @Inject constructor( |
||||
@ApplicationContext private val context: Context, |
||||
private val dispatchers: CoroutineDispatchers, |
||||
private val buildMeta: BuildMeta, |
||||
) : FileShare { |
||||
override suspend fun share( |
||||
path: String, |
||||
) { |
||||
runCatching { |
||||
val file = File(path) |
||||
val shareableUri = file.toShareableUri() |
||||
val shareMediaIntent = Intent(Intent.ACTION_SEND) |
||||
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) |
||||
.putExtra(Intent.EXTRA_STREAM, shareableUri) |
||||
.setTypeAndNormalize(MimeTypes.OctetStream) |
||||
withContext(dispatchers.main) { |
||||
val intent = Intent.createChooser(shareMediaIntent, null) |
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) |
||||
context.startActivity(intent) |
||||
} |
||||
}.onSuccess { |
||||
Timber.v("Share file succeed") |
||||
}.onFailure { |
||||
Timber.e(it, "Share file failed") |
||||
} |
||||
} |
||||
|
||||
private fun File.toShareableUri(): Uri { |
||||
val authority = "${buildMeta.applicationId}.fileprovider" |
||||
return FileProvider.getUriForFile(context, authority, this).normalizeScheme() |
||||
} |
||||
} |
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.file |
||||
|
||||
sealed interface ViewFileEvents { |
||||
data object SaveOnDisk : ViewFileEvents |
||||
data object Share : ViewFileEvents |
||||
} |
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.file |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import com.bumble.appyx.core.modality.BuildContext |
||||
import com.bumble.appyx.core.node.Node |
||||
import com.bumble.appyx.core.plugin.Plugin |
||||
import com.bumble.appyx.core.plugin.plugins |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedInject |
||||
import io.element.android.anvilannotations.ContributesNode |
||||
import io.element.android.libraries.architecture.NodeInputs |
||||
import io.element.android.libraries.architecture.inputs |
||||
import io.element.android.libraries.di.AppScope |
||||
|
||||
@ContributesNode(AppScope::class) |
||||
class ViewFileNode @AssistedInject constructor( |
||||
@Assisted buildContext: BuildContext, |
||||
@Assisted plugins: List<Plugin>, |
||||
presenterFactory: ViewFilePresenter.Factory, |
||||
) : Node(buildContext, plugins = plugins) { |
||||
data class Inputs( |
||||
val path: String, |
||||
val name: String, |
||||
) : NodeInputs |
||||
|
||||
interface Callback : Plugin { |
||||
fun onBackPressed() |
||||
} |
||||
|
||||
private val inputs: Inputs = inputs() |
||||
|
||||
private val presenter = presenterFactory.create( |
||||
path = inputs.path, |
||||
name = inputs.name, |
||||
) |
||||
|
||||
private fun onBackPressed() { |
||||
plugins<Callback>().forEach { it.onBackPressed() } |
||||
} |
||||
|
||||
@Composable |
||||
override fun View(modifier: Modifier) { |
||||
val state = presenter.present() |
||||
ViewFileView( |
||||
state = state, |
||||
modifier = modifier, |
||||
onBackPressed = ::onBackPressed, |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.file |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.LaunchedEffect |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.rememberCoroutineScope |
||||
import androidx.compose.runtime.setValue |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedFactory |
||||
import dagger.assisted.AssistedInject |
||||
import io.element.android.libraries.architecture.Presenter |
||||
import kotlinx.collections.immutable.toImmutableList |
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlinx.coroutines.launch |
||||
|
||||
class ViewFilePresenter @AssistedInject constructor( |
||||
@Assisted("path") val path: String, |
||||
@Assisted("name") val name: String, |
||||
private val fileContentReader: FileContentReader, |
||||
private val fileShare: FileShare, |
||||
private val fileSave: FileSave, |
||||
) : Presenter<ViewFileState> { |
||||
@AssistedFactory |
||||
interface Factory { |
||||
fun create( |
||||
@Assisted("path") path: String, |
||||
@Assisted("name") name: String, |
||||
): ViewFilePresenter |
||||
} |
||||
|
||||
@Composable |
||||
override fun present(): ViewFileState { |
||||
val coroutineScope = rememberCoroutineScope() |
||||
|
||||
fun handleEvent(event: ViewFileEvents) { |
||||
when (event) { |
||||
ViewFileEvents.Share -> coroutineScope.share(path) |
||||
ViewFileEvents.SaveOnDisk -> coroutineScope.save(path) |
||||
} |
||||
} |
||||
|
||||
var lines by remember { mutableStateOf(emptyList<String>()) } |
||||
LaunchedEffect(Unit) { |
||||
lines = fileContentReader.getLines(path) |
||||
} |
||||
return ViewFileState( |
||||
name = name, |
||||
lines = lines.toImmutableList(), |
||||
eventSink = ::handleEvent, |
||||
) |
||||
} |
||||
|
||||
private fun CoroutineScope.share(path: String) = launch { |
||||
fileShare.share(path) |
||||
} |
||||
|
||||
private fun CoroutineScope.save(path: String) = launch { |
||||
fileSave.save(path) |
||||
} |
||||
} |
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.file |
||||
|
||||
import kotlinx.collections.immutable.ImmutableList |
||||
|
||||
data class ViewFileState( |
||||
val name: String, |
||||
val lines: ImmutableList<String>, |
||||
val eventSink: (ViewFileEvents) -> Unit, |
||||
) |
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.file |
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||
import kotlinx.collections.immutable.toImmutableList |
||||
|
||||
open class ViewFileStateProvider : PreviewParameterProvider<ViewFileState> { |
||||
override val values: Sequence<ViewFileState> |
||||
get() = sequenceOf( |
||||
aViewFileState(), |
||||
aViewFileState( |
||||
lines = listOf( |
||||
"Line 1", |
||||
"Line 2", |
||||
"Line 3 lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor" + |
||||
" incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,", |
||||
"01-23 13:14:50.740 25818 25818 V verbose", |
||||
"01-23 13:14:50.740 25818 25818 D debug", |
||||
"01-23 13:14:50.740 25818 25818 I info", |
||||
"01-23 13:14:50.740 25818 25818 W warning", |
||||
"01-23 13:14:50.740 25818 25818 E error", |
||||
"01-23 13:14:50.740 25818 25818 A assertion", |
||||
) |
||||
) |
||||
) |
||||
} |
||||
|
||||
fun aViewFileState( |
||||
name: String = "aName", |
||||
lines: List<String> = emptyList(), |
||||
) = ViewFileState( |
||||
name = name, |
||||
lines = lines.toImmutableList(), |
||||
eventSink = {}, |
||||
) |
@ -0,0 +1,206 @@
@@ -0,0 +1,206 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.file |
||||
|
||||
import androidx.compose.foundation.clickable |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.consumeWindowInsets |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.layout.widthIn |
||||
import androidx.compose.foundation.lazy.LazyColumn |
||||
import androidx.compose.foundation.lazy.itemsIndexed |
||||
import androidx.compose.material3.ExperimentalMaterial3Api |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.draw.drawWithContent |
||||
import androidx.compose.ui.geometry.Offset |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.platform.LocalContext |
||||
import androidx.compose.ui.res.stringResource |
||||
import androidx.compose.ui.text.style.TextAlign |
||||
import androidx.compose.ui.tooling.preview.PreviewParameter |
||||
import androidx.compose.ui.unit.dp |
||||
import io.element.android.compound.theme.ElementTheme |
||||
import io.element.android.libraries.androidutils.system.copyToClipboard |
||||
import io.element.android.libraries.designsystem.components.button.BackButton |
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables |
||||
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle |
||||
import io.element.android.libraries.designsystem.theme.components.Icon |
||||
import io.element.android.libraries.designsystem.theme.components.IconButton |
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold |
||||
import io.element.android.libraries.designsystem.theme.components.Text |
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar |
||||
import io.element.android.libraries.ui.strings.CommonStrings |
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class) |
||||
@Composable |
||||
fun ViewFileView( |
||||
state: ViewFileState, |
||||
onBackPressed: () -> Unit, |
||||
modifier: Modifier = Modifier, |
||||
) { |
||||
Scaffold( |
||||
modifier = modifier, |
||||
topBar = { |
||||
TopAppBar( |
||||
navigationIcon = { |
||||
BackButton(onClick = onBackPressed) |
||||
}, |
||||
title = { |
||||
Text( |
||||
text = state.name, |
||||
style = ElementTheme.typography.aliasScreenTitle, |
||||
) |
||||
}, |
||||
actions = { |
||||
IconButton( |
||||
onClick = { |
||||
state.eventSink(ViewFileEvents.Share) |
||||
}, |
||||
) { |
||||
Icon( |
||||
resourceId = CompoundDrawables.ic_share_android, |
||||
contentDescription = stringResource(id = CommonStrings.action_share), |
||||
) |
||||
} |
||||
IconButton( |
||||
onClick = { |
||||
state.eventSink(ViewFileEvents.SaveOnDisk) |
||||
}, |
||||
) { |
||||
Icon( |
||||
resourceId = CompoundDrawables.ic_download, |
||||
contentDescription = stringResource(id = CommonStrings.action_save), |
||||
) |
||||
} |
||||
} |
||||
) |
||||
}, |
||||
content = { padding -> |
||||
Column( |
||||
modifier = Modifier |
||||
.padding(padding) |
||||
.consumeWindowInsets(padding) |
||||
) { |
||||
LazyColumn( |
||||
modifier = Modifier.weight(1f) |
||||
) { |
||||
if (state.lines.isEmpty()) { |
||||
item { |
||||
Spacer(Modifier.size(80.dp)) |
||||
Text( |
||||
text = "Empty file", |
||||
textAlign = TextAlign.Center, |
||||
color = MaterialTheme.colorScheme.tertiary, |
||||
modifier = Modifier.fillMaxWidth() |
||||
) |
||||
} |
||||
} else { |
||||
itemsIndexed( |
||||
items = state.lines, |
||||
) { index, line -> |
||||
LineRow( |
||||
lineNumber = index + 1, |
||||
line = line, |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
) |
||||
} |
||||
|
||||
@Composable |
||||
private fun LineRow( |
||||
lineNumber: Int, |
||||
line: String, |
||||
) { |
||||
val context = LocalContext.current |
||||
Row( |
||||
modifier = Modifier |
||||
.fillMaxWidth() |
||||
.clickable(onClick = { |
||||
context.copyToClipboard( |
||||
line, |
||||
"Line copied to clipboard", |
||||
) |
||||
}) |
||||
) { |
||||
Text( |
||||
modifier = Modifier |
||||
.widthIn(min = 36.dp) |
||||
.padding(horizontal = 4.dp), |
||||
text = "$lineNumber", |
||||
textAlign = TextAlign.End, |
||||
color = ElementTheme.colors.textSecondary, |
||||
style = ElementTheme.typography.fontBodyMdMedium, |
||||
) |
||||
val color = ElementTheme.colors.textSecondary |
||||
val width = 0.5.dp.value |
||||
Text( |
||||
modifier = Modifier |
||||
.weight(1f) |
||||
.drawWithContent { |
||||
// Using .height(IntrinsicSize.Min) on the Row does not work well inside LazyColumn |
||||
drawLine( |
||||
color = color, |
||||
start = Offset(0f, 0f), |
||||
end = Offset(0f, size.height), |
||||
strokeWidth = width |
||||
) |
||||
drawContent() |
||||
} |
||||
.padding(horizontal = 4.dp), |
||||
text = line, |
||||
color = line.toColor(), |
||||
style = ElementTheme.typography.fontBodyMdRegular |
||||
) |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Convert a logcat line to a color. |
||||
* Ex: `01-23 13:14:50.740 25818 25818 D org.matrix.rust.sdk: elementx: SyncIndicator = Hide | RustRoomListService.kt:81` |
||||
*/ |
||||
@Composable |
||||
private fun String.toColor(): Color { |
||||
return when (getOrNull(31)) { |
||||
'D' -> Color(0xFF299999) |
||||
'I' -> Color(0xFFABC023) |
||||
'W' -> Color(0xFFBBB529) |
||||
'E' -> Color(0xFFFF6B68) |
||||
'A' -> Color(0xFFFF6B68) |
||||
else -> ElementTheme.colors.textPrimary |
||||
} |
||||
} |
||||
|
||||
@PreviewsDayNight |
||||
@Composable |
||||
internal fun ViewFileViewPreview(@PreviewParameter(ViewFileStateProvider::class) state: ViewFileState) = ElementPreview { |
||||
ViewFileView( |
||||
state = state, |
||||
onBackPressed = {}, |
||||
) |
||||
} |
@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.folder |
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding |
||||
import io.element.android.features.viewfolder.impl.model.Item |
||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter |
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers |
||||
import io.element.android.libraries.di.AppScope |
||||
import kotlinx.coroutines.withContext |
||||
import java.io.File |
||||
import javax.inject.Inject |
||||
|
||||
interface FolderExplorer { |
||||
suspend fun getItems(path: String): List<Item> |
||||
} |
||||
|
||||
@ContributesBinding(AppScope::class) |
||||
class DefaultFolderExplorer @Inject constructor( |
||||
private val fileSizeFormatter: FileSizeFormatter, |
||||
private val dispatchers: CoroutineDispatchers, |
||||
) : FolderExplorer { |
||||
override suspend fun getItems(path: String): List<Item> = withContext(dispatchers.io) { |
||||
val current = File(path) |
||||
if (current.isFile) { |
||||
error("Not a folder") |
||||
} |
||||
val folderContent = current.listFiles().orEmpty().map { file -> |
||||
if (file.isDirectory) { |
||||
Item.Folder( |
||||
path = file.path, |
||||
name = file.name |
||||
) |
||||
} else { |
||||
Item.File( |
||||
path = file.path, |
||||
name = file.name, |
||||
formattedSize = fileSizeFormatter.format(file.length()), |
||||
) |
||||
} |
||||
} |
||||
buildList { |
||||
addAll(folderContent.filterIsInstance<Item.Folder>().sortedBy(Item.Folder::name)) |
||||
addAll(folderContent.filterIsInstance<Item.File>().sortedBy(Item.File::name)) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.folder |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import com.bumble.appyx.core.modality.BuildContext |
||||
import com.bumble.appyx.core.node.Node |
||||
import com.bumble.appyx.core.plugin.Plugin |
||||
import com.bumble.appyx.core.plugin.plugins |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedInject |
||||
import io.element.android.anvilannotations.ContributesNode |
||||
import io.element.android.features.viewfolder.impl.model.Item |
||||
import io.element.android.libraries.architecture.NodeInputs |
||||
import io.element.android.libraries.architecture.inputs |
||||
import io.element.android.libraries.di.AppScope |
||||
|
||||
@ContributesNode(AppScope::class) |
||||
class ViewFolderNode @AssistedInject constructor( |
||||
@Assisted buildContext: BuildContext, |
||||
@Assisted plugins: List<Plugin>, |
||||
presenterFactory: ViewFolderPresenter.Factory, |
||||
) : Node(buildContext, plugins = plugins) { |
||||
data class Inputs( |
||||
val canGoUp: Boolean, |
||||
val path: String, |
||||
) : NodeInputs |
||||
|
||||
interface Callback : Plugin { |
||||
fun onBackPressed() |
||||
fun onNavigateTo(item: Item) |
||||
} |
||||
|
||||
private val inputs: Inputs = inputs() |
||||
|
||||
private val presenter = presenterFactory.create( |
||||
canGoUp = inputs.canGoUp, |
||||
path = inputs.path, |
||||
) |
||||
|
||||
private fun onBackPressed() { |
||||
plugins<Callback>().forEach { it.onBackPressed() } |
||||
} |
||||
|
||||
private fun onNavigateTo(item: Item) { |
||||
plugins<Callback>().forEach { it.onNavigateTo(item) } |
||||
} |
||||
|
||||
@Composable |
||||
override fun View(modifier: Modifier) { |
||||
val state = presenter.present() |
||||
ViewFolderView( |
||||
state = state, |
||||
modifier = modifier, |
||||
onNavigateTo = ::onNavigateTo, |
||||
onBackPressed = ::onBackPressed, |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.folder |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.LaunchedEffect |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.setValue |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedFactory |
||||
import dagger.assisted.AssistedInject |
||||
import io.element.android.features.viewfolder.impl.model.Item |
||||
import io.element.android.libraries.architecture.Presenter |
||||
import kotlinx.collections.immutable.toImmutableList |
||||
|
||||
class ViewFolderPresenter @AssistedInject constructor( |
||||
@Assisted val canGoUp: Boolean, |
||||
@Assisted val path: String, |
||||
private val folderExplorer: FolderExplorer, |
||||
) : Presenter<ViewFolderState> { |
||||
@AssistedFactory |
||||
interface Factory { |
||||
fun create(canGoUp: Boolean, path: String): ViewFolderPresenter |
||||
} |
||||
|
||||
@Composable |
||||
override fun present(): ViewFolderState { |
||||
var content by remember { mutableStateOf(emptyList<Item>()) } |
||||
LaunchedEffect(Unit) { |
||||
content = buildList { |
||||
if (canGoUp) add(Item.Parent) |
||||
addAll(folderExplorer.getItems(path)) |
||||
} |
||||
} |
||||
return ViewFolderState( |
||||
path = path, |
||||
content = content.toImmutableList(), |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.folder |
||||
|
||||
import io.element.android.features.viewfolder.impl.model.Item |
||||
import kotlinx.collections.immutable.ImmutableList |
||||
|
||||
data class ViewFolderState( |
||||
val path: String, |
||||
val content: ImmutableList<Item>, |
||||
) |
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.folder |
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||
import io.element.android.features.viewfolder.impl.model.Item |
||||
import kotlinx.collections.immutable.toImmutableList |
||||
|
||||
open class ViewFolderStateProvider : PreviewParameterProvider<ViewFolderState> { |
||||
override val values: Sequence<ViewFolderState> |
||||
get() = sequenceOf( |
||||
aViewFolderState(), |
||||
aViewFolderState( |
||||
content = listOf( |
||||
Item.Parent, |
||||
Item.Folder("aPath", "aFolder"), |
||||
Item.File("aPath", "aFile", "12kB"), |
||||
) |
||||
) |
||||
) |
||||
} |
||||
|
||||
fun aViewFolderState( |
||||
path: String = "aPath", |
||||
content: List<Item> = emptyList(), |
||||
) = ViewFolderState( |
||||
path = path, |
||||
content = content.toImmutableList(), |
||||
) |
@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.folder |
||||
|
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.consumeWindowInsets |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.lazy.LazyColumn |
||||
import androidx.compose.foundation.lazy.items |
||||
import androidx.compose.material.icons.Icons |
||||
import androidx.compose.material.icons.outlined.Description |
||||
import androidx.compose.material.icons.outlined.Folder |
||||
import androidx.compose.material.icons.outlined.SubdirectoryArrowLeft |
||||
import androidx.compose.material3.ExperimentalMaterial3Api |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.text.style.TextAlign |
||||
import androidx.compose.ui.tooling.preview.PreviewParameter |
||||
import androidx.compose.ui.unit.dp |
||||
import io.element.android.compound.theme.ElementTheme |
||||
import io.element.android.features.viewfolder.impl.model.Item |
||||
import io.element.android.libraries.designsystem.components.button.BackButton |
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent |
||||
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle |
||||
import io.element.android.libraries.designsystem.theme.components.IconSource |
||||
import io.element.android.libraries.designsystem.theme.components.ListItem |
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold |
||||
import io.element.android.libraries.designsystem.theme.components.Text |
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar |
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class) |
||||
@Composable |
||||
fun ViewFolderView( |
||||
state: ViewFolderState, |
||||
onNavigateTo: (Item) -> Unit, |
||||
onBackPressed: () -> Unit, |
||||
modifier: Modifier = Modifier, |
||||
) { |
||||
Scaffold( |
||||
modifier = modifier, |
||||
topBar = { |
||||
TopAppBar( |
||||
navigationIcon = { |
||||
BackButton(onClick = onBackPressed) |
||||
}, |
||||
title = { |
||||
Text( |
||||
text = state.path, |
||||
style = ElementTheme.typography.aliasScreenTitle, |
||||
) |
||||
} |
||||
) |
||||
}, |
||||
content = { padding -> |
||||
Column( |
||||
modifier = Modifier |
||||
.padding(padding) |
||||
.consumeWindowInsets(padding) |
||||
) { |
||||
LazyColumn( |
||||
modifier = Modifier.weight(1f) |
||||
) { |
||||
items( |
||||
items = state.content, |
||||
) { item -> |
||||
ItemRow( |
||||
item = item, |
||||
onItemClicked = { onNavigateTo(item) }, |
||||
) |
||||
} |
||||
if (state.content.none { it !is Item.Parent }) { |
||||
item { |
||||
Spacer(Modifier.size(80.dp)) |
||||
Text( |
||||
text = "Empty folder", |
||||
textAlign = TextAlign.Center, |
||||
color = MaterialTheme.colorScheme.tertiary, |
||||
modifier = Modifier.fillMaxWidth() |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
) |
||||
} |
||||
|
||||
@Composable |
||||
private fun ItemRow( |
||||
item: Item, |
||||
onItemClicked: () -> Unit, |
||||
) { |
||||
when (item) { |
||||
Item.Parent -> { |
||||
ListItem( |
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(Icons.Outlined.SubdirectoryArrowLeft)), |
||||
headlineContent = { |
||||
Text( |
||||
text = "..", |
||||
modifier = Modifier.padding(16.dp), |
||||
style = ElementTheme.typography.fontBodyMdMedium, |
||||
) |
||||
}, |
||||
onClick = onItemClicked, |
||||
) |
||||
} |
||||
is Item.Folder -> { |
||||
ListItem( |
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(Icons.Outlined.Folder)), |
||||
headlineContent = { |
||||
Text( |
||||
text = item.name, |
||||
modifier = Modifier.padding(16.dp), |
||||
style = ElementTheme.typography.fontBodyMdMedium, |
||||
) |
||||
}, |
||||
onClick = onItemClicked, |
||||
) |
||||
} |
||||
is Item.File -> { |
||||
ListItem( |
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(Icons.Outlined.Description)), |
||||
headlineContent = { |
||||
Text( |
||||
text = item.name, |
||||
modifier = Modifier.padding(16.dp), |
||||
style = ElementTheme.typography.fontBodyMdMedium, |
||||
) |
||||
}, |
||||
trailingContent = ListItemContent.Text(item.formattedSize), |
||||
onClick = onItemClicked, |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@PreviewsDayNight |
||||
@Composable |
||||
internal fun ViewFolderViewPreview(@PreviewParameter(ViewFolderStateProvider::class) state: ViewFolderState) = ElementPreview { |
||||
ViewFolderView( |
||||
state = state, |
||||
onNavigateTo = {}, |
||||
onBackPressed = {}, |
||||
) |
||||
} |
@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.model |
||||
|
||||
import androidx.compose.runtime.Immutable |
||||
|
||||
@Immutable |
||||
sealed interface Item { |
||||
data object Parent : Item |
||||
|
||||
data class Folder( |
||||
val path: String, |
||||
val name: String, |
||||
) : Item |
||||
|
||||
data class File( |
||||
val path: String, |
||||
val name: String, |
||||
val formattedSize: String, |
||||
) : Item |
||||
} |
@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.impl.root |
||||
|
||||
import android.os.Parcelable |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import com.bumble.appyx.core.modality.BuildContext |
||||
import com.bumble.appyx.core.node.Node |
||||
import com.bumble.appyx.core.plugin.Plugin |
||||
import com.bumble.appyx.core.plugin.plugins |
||||
import com.bumble.appyx.navmodel.backstack.BackStack |
||||
import com.bumble.appyx.navmodel.backstack.operation.pop |
||||
import com.bumble.appyx.navmodel.backstack.operation.push |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedInject |
||||
import io.element.android.anvilannotations.ContributesNode |
||||
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint |
||||
import io.element.android.features.viewfolder.impl.file.ViewFileNode |
||||
import io.element.android.features.viewfolder.impl.folder.ViewFolderNode |
||||
import io.element.android.features.viewfolder.impl.model.Item |
||||
import io.element.android.libraries.architecture.BackstackView |
||||
import io.element.android.libraries.architecture.BaseFlowNode |
||||
import io.element.android.libraries.architecture.NodeInputs |
||||
import io.element.android.libraries.architecture.createNode |
||||
import io.element.android.libraries.architecture.inputs |
||||
import io.element.android.libraries.di.AppScope |
||||
import kotlinx.parcelize.Parcelize |
||||
|
||||
@ContributesNode(AppScope::class) |
||||
class ViewFolderRootNode @AssistedInject constructor( |
||||
@Assisted buildContext: BuildContext, |
||||
@Assisted plugins: List<Plugin>, |
||||
) : BaseFlowNode<ViewFolderRootNode.NavTarget>( |
||||
backstack = BackStack( |
||||
initialElement = NavTarget.Root, |
||||
savedStateMap = buildContext.savedStateMap, |
||||
), |
||||
buildContext = buildContext, |
||||
plugins = plugins |
||||
) { |
||||
sealed interface NavTarget : Parcelable { |
||||
@Parcelize |
||||
data object Root : NavTarget |
||||
|
||||
@Parcelize |
||||
data class Folder( |
||||
val path: String, |
||||
) : NavTarget |
||||
|
||||
@Parcelize |
||||
data class File( |
||||
val path: String, |
||||
val name: String, |
||||
) : NavTarget |
||||
} |
||||
|
||||
data class Inputs( |
||||
val rootPath: String, |
||||
) : NodeInputs |
||||
|
||||
private val inputs: Inputs = inputs() |
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { |
||||
return when (navTarget) { |
||||
is NavTarget.Root -> { |
||||
createViewFolderNode( |
||||
buildContext, |
||||
inputs = ViewFolderNode.Inputs( |
||||
canGoUp = false, |
||||
path = inputs.rootPath, |
||||
) |
||||
) |
||||
} |
||||
is NavTarget.Folder -> { |
||||
createViewFolderNode( |
||||
buildContext, |
||||
inputs = ViewFolderNode.Inputs( |
||||
canGoUp = true, |
||||
path = navTarget.path, |
||||
) |
||||
) |
||||
} |
||||
is NavTarget.File -> { |
||||
val callback: ViewFileNode.Callback = object : ViewFileNode.Callback { |
||||
override fun onBackPressed() { |
||||
backstack.pop() |
||||
} |
||||
} |
||||
val inputs = ViewFileNode.Inputs( |
||||
path = navTarget.path, |
||||
name = navTarget.name, |
||||
) |
||||
createNode<ViewFileNode>(buildContext, plugins = listOf(inputs, callback)) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun createViewFolderNode( |
||||
buildContext: BuildContext, |
||||
inputs: ViewFolderNode.Inputs, |
||||
): Node { |
||||
val callback: ViewFolderNode.Callback = object : ViewFolderNode.Callback { |
||||
override fun onBackPressed() { |
||||
onDone() |
||||
} |
||||
|
||||
override fun onNavigateTo(item: Item) { |
||||
when (item) { |
||||
Item.Parent -> { |
||||
// Should not happen when in Root since parent is not accessible from root (canGoUp set to false) |
||||
backstack.pop() |
||||
} |
||||
is Item.Folder -> { |
||||
backstack.push(NavTarget.Folder(path = item.path)) |
||||
} |
||||
is Item.File -> { |
||||
backstack.push(NavTarget.File(path = item.path, name = item.name)) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
return createNode<ViewFolderNode>(buildContext, plugins = listOf(inputs, callback)) |
||||
} |
||||
|
||||
@Composable |
||||
override fun View(modifier: Modifier) { |
||||
BackstackView() |
||||
} |
||||
|
||||
private fun onDone() { |
||||
plugins<ViewFolderEntryPoint.Callback>().forEach { it.onDone() } |
||||
} |
||||
} |
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.test.file |
||||
|
||||
import io.element.android.features.viewfolder.impl.file.FileContentReader |
||||
|
||||
class FakeFileContentReader : FileContentReader { |
||||
private var result: List<String> = emptyList() |
||||
|
||||
fun givenResult(result: List<String>) { |
||||
this.result = result |
||||
} |
||||
|
||||
override suspend fun getLines(path: String): List<String> = result |
||||
} |
@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.test.file |
||||
|
||||
import io.element.android.features.viewfolder.impl.file.FileSave |
||||
|
||||
class FakeFileSave : FileSave { |
||||
var hasBeenCalled = false |
||||
private set |
||||
|
||||
override suspend fun save(path: String) { |
||||
hasBeenCalled = true |
||||
} |
||||
} |
@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.test.file |
||||
|
||||
import io.element.android.features.viewfolder.impl.file.FileShare |
||||
|
||||
class FakeFileShare : FileShare { |
||||
var hasBeenCalled = false |
||||
private set |
||||
|
||||
override suspend fun share(path: String) { |
||||
hasBeenCalled = true |
||||
} |
||||
} |
@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.test.file |
||||
|
||||
import app.cash.molecule.RecompositionMode |
||||
import app.cash.molecule.moleculeFlow |
||||
import app.cash.turbine.test |
||||
import com.google.common.truth.Truth.assertThat |
||||
import io.element.android.features.viewfolder.impl.file.FileContentReader |
||||
import io.element.android.features.viewfolder.impl.file.FileSave |
||||
import io.element.android.features.viewfolder.impl.file.FileShare |
||||
import io.element.android.features.viewfolder.impl.file.ViewFileEvents |
||||
import io.element.android.features.viewfolder.impl.file.ViewFilePresenter |
||||
import io.element.android.tests.testutils.WarmUpRule |
||||
import kotlinx.coroutines.test.runTest |
||||
import org.junit.Rule |
||||
import org.junit.Test |
||||
|
||||
class ViewFilePresenterTest { |
||||
@get:Rule |
||||
val warmUpRule = WarmUpRule() |
||||
|
||||
@Test |
||||
fun `present - initial state`() = runTest { |
||||
val fileContentReader = FakeFileContentReader().apply { |
||||
givenResult(listOf("aLine")) |
||||
} |
||||
val presenter = createPresenter(fileContentReader = fileContentReader) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
skipItems(1) |
||||
val initialState = awaitItem() |
||||
assertThat(initialState.name).isEqualTo("aName") |
||||
assertThat(initialState.lines.size).isEqualTo(1) |
||||
assertThat(initialState.lines.first()).isEqualTo("aLine") |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - share should not have any side effect`() = runTest { |
||||
val fileContentReader = FakeFileContentReader().apply { |
||||
givenResult(listOf("aLine")) |
||||
} |
||||
val fileShare = FakeFileShare() |
||||
val fileSave = FakeFileSave() |
||||
val presenter = createPresenter(fileContentReader = fileContentReader, fileShare = fileShare, fileSave = fileSave) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
skipItems(1) |
||||
val initialState = awaitItem() |
||||
initialState.eventSink(ViewFileEvents.Share) |
||||
assertThat(fileShare.hasBeenCalled).isTrue() |
||||
assertThat(fileSave.hasBeenCalled).isFalse() |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - save should not have any side effect`() = runTest { |
||||
val fileContentReader = FakeFileContentReader().apply { |
||||
givenResult(listOf("aLine")) |
||||
} |
||||
val fileShare = FakeFileShare() |
||||
val fileSave = FakeFileSave() |
||||
val presenter = createPresenter(fileContentReader = fileContentReader, fileShare = fileShare, fileSave = fileSave) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
skipItems(1) |
||||
val initialState = awaitItem() |
||||
initialState.eventSink(ViewFileEvents.SaveOnDisk) |
||||
assertThat(fileShare.hasBeenCalled).isFalse() |
||||
assertThat(fileSave.hasBeenCalled).isTrue() |
||||
} |
||||
} |
||||
|
||||
private fun createPresenter( |
||||
path: String = "aPath", |
||||
name: String = "aName", |
||||
fileContentReader: FileContentReader = FakeFileContentReader(), |
||||
fileShare: FileShare = FakeFileShare(), |
||||
fileSave: FileSave = FakeFileSave(), |
||||
) = ViewFilePresenter( |
||||
path = path, |
||||
name = name, |
||||
fileContentReader = fileContentReader, |
||||
fileShare = fileShare, |
||||
fileSave = fileSave, |
||||
) |
||||
} |
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.test.folder |
||||
|
||||
import io.element.android.features.viewfolder.impl.folder.FolderExplorer |
||||
import io.element.android.features.viewfolder.impl.model.Item |
||||
|
||||
class FakeFolderExplorer : FolderExplorer { |
||||
private var result: List<Item> = emptyList() |
||||
|
||||
fun givenResult(result: List<Item>) { |
||||
this.result = result |
||||
} |
||||
|
||||
override suspend fun getItems(path: String): List<Item> = result |
||||
} |
@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
|
||||
/* |
||||
* Copyright (c) 2024 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.viewfolder.test.folder |
||||
|
||||
import app.cash.molecule.RecompositionMode |
||||
import app.cash.molecule.moleculeFlow |
||||
import app.cash.turbine.test |
||||
import com.google.common.truth.Truth.assertThat |
||||
import io.element.android.features.viewfolder.impl.folder.FolderExplorer |
||||
import io.element.android.features.viewfolder.impl.folder.ViewFolderPresenter |
||||
import io.element.android.features.viewfolder.impl.model.Item |
||||
import io.element.android.tests.testutils.WarmUpRule |
||||
import kotlinx.coroutines.test.runTest |
||||
import org.junit.Rule |
||||
import org.junit.Test |
||||
|
||||
class ViewFolderPresenterTest { |
||||
@get:Rule |
||||
val warmUpRule = WarmUpRule() |
||||
|
||||
@Test |
||||
fun `present - initial state`() = runTest { |
||||
val presenter = createPresenter() |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem() |
||||
assertThat(initialState.path).isEqualTo("aPath") |
||||
assertThat(initialState.content).isEmpty() |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - list items from root`() = runTest { |
||||
val items = listOf( |
||||
Item.Folder("aFilePath", "aFilename"), |
||||
Item.File("aFolderPath", "aFolderName", "aSize"), |
||||
) |
||||
val folderExplorer = FakeFolderExplorer().apply { |
||||
givenResult(items) |
||||
} |
||||
val presenter = createPresenter(folderExplorer = folderExplorer) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
skipItems(1) |
||||
val initialState = awaitItem() |
||||
assertThat(initialState.path).isEqualTo("aPath") |
||||
assertThat(initialState.content.toList()).isEqualTo(items) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - list items from a folder`() = runTest { |
||||
val items = listOf( |
||||
Item.Folder("aFilePath", "aFilename"), |
||||
Item.File("aFolderPath", "aFolderName", "aSize"), |
||||
) |
||||
val folderExplorer = FakeFolderExplorer().apply { |
||||
givenResult(items) |
||||
} |
||||
val presenter = createPresenter( |
||||
canGoUp = true, |
||||
folderExplorer = folderExplorer |
||||
) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
skipItems(1) |
||||
val initialState = awaitItem() |
||||
assertThat(initialState.path).isEqualTo("aPath") |
||||
assertThat(initialState.content.toList()).isEqualTo(listOf(Item.Parent) + items) |
||||
} |
||||
} |
||||
|
||||
private fun createPresenter( |
||||
canGoUp: Boolean = false, |
||||
path: String = "aPath", |
||||
folderExplorer: FolderExplorer = FakeFolderExplorer(), |
||||
) = ViewFolderPresenter( |
||||
path = path, |
||||
canGoUp = canGoUp, |
||||
folderExplorer = folderExplorer, |
||||
) |
||||
} |
Loading…
Reference in new issue