Benoit Marty
8 months ago
35 changed files with 1817 additions and 15 deletions
@ -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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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