Merge branch 'develop' into renovate/io.nlopez.compose.rules-detekt-0.x
This commit is contained in:
commit
628149ff62
43 changed files with 309 additions and 325 deletions
6
.github/renovate.json
vendored
6
.github/renovate.json
vendored
|
|
@ -18,12 +18,6 @@
|
|||
],
|
||||
"groupName" : "kotlin"
|
||||
},
|
||||
{
|
||||
"matchPackageNames" : [
|
||||
"org.jetbrains.kotlinx.kover"
|
||||
],
|
||||
"enabled" : false
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns" : [
|
||||
"^org.maplibre"
|
||||
|
|
|
|||
4
.github/workflows/nightlyReports.yml
vendored
4
.github/workflows/nightlyReports.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: 📈 Generate kover report and verify coverage
|
||||
run: ./gradlew koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
|
||||
run: ./gradlew :app:koverHtmlReport :app:koverXmlReport :app:koverVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
|
||||
|
||||
- name: ✅ Upload kover report
|
||||
if: always()
|
||||
|
|
@ -41,7 +41,7 @@ jobs:
|
|||
with:
|
||||
name: kover-results
|
||||
path: |
|
||||
**/build/reports/kover/merged
|
||||
**/build/reports/kover
|
||||
|
||||
- name: 🔊 Publish results to Sonar
|
||||
env:
|
||||
|
|
|
|||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: 📈Generate kover report and verify coverage
|
||||
run: ./gradlew koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
|
||||
run: ./gradlew :app:koverHtmlReport :app:koverXmlReport :app:koverVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
|
||||
|
||||
- name: 🚫 Upload kover failed coverage reports
|
||||
if: failure()
|
||||
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
with:
|
||||
name: kover-error-report
|
||||
path: |
|
||||
**/kover/merged/verification/errors.txt
|
||||
app/build/reports/kover/verify.err
|
||||
|
||||
- name: ✅ Upload kover report (disabled)
|
||||
if: always()
|
||||
|
|
@ -83,4 +83,4 @@ jobs:
|
|||
if: always()
|
||||
uses: codecov/codecov-action@v3
|
||||
# with:
|
||||
# files: build/reports/kover/merged/xml/report.xml
|
||||
# files: build/reports/kover/xml/report.xml
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import com.android.build.api.variant.FilterConfiguration.FilterType.ABI
|
|||
import extension.allFeaturesImpl
|
||||
import extension.allLibrariesImpl
|
||||
import extension.allServicesImpl
|
||||
import org.jetbrains.kotlin.cli.common.toBooleanLenient
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-application")
|
||||
|
|
@ -190,6 +191,220 @@ knit {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kover configuration
|
||||
*/
|
||||
|
||||
dependencies {
|
||||
// Add all sub projects to kover except some of them
|
||||
project.rootProject.subprojects
|
||||
.filter {
|
||||
it.project.projectDir.resolve("build.gradle.kts").exists()
|
||||
}
|
||||
.map { it.path }
|
||||
.sorted()
|
||||
.filter {
|
||||
it !in listOf(
|
||||
":app",
|
||||
":samples",
|
||||
":anvilannotations",
|
||||
":anvilcodegen",
|
||||
":samples:minimal",
|
||||
":tests:testutils",
|
||||
// Exclude `:libraries:matrix:impl` module, it contains only wrappers to access the Rust Matrix
|
||||
// SDK api, so it is not really relevant to unit test it: there is no logic to test.
|
||||
":libraries:matrix:impl",
|
||||
// Exclude modules which are not Android libraries
|
||||
// See https://github.com/Kotlin/kotlinx-kover/issues/312
|
||||
":appconfig",
|
||||
":libraries:core",
|
||||
":libraries:coroutines",
|
||||
":libraries:di",
|
||||
":libraries:rustsdk",
|
||||
":libraries:textcomposer:lib",
|
||||
)
|
||||
}
|
||||
.forEach {
|
||||
// println("Add $it to kover")
|
||||
kover(project(it))
|
||||
}
|
||||
}
|
||||
|
||||
val ciBuildProperty = "ci-build"
|
||||
val isCiBuild = if (project.hasProperty(ciBuildProperty)) {
|
||||
val raw = project.property(ciBuildProperty) as? String
|
||||
raw?.toBooleanLenient() == true || raw?.toIntOrNull() == 1
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
kover {
|
||||
// When running on the CI, run only debug test variants
|
||||
if (isCiBuild) {
|
||||
excludeTests {
|
||||
// Disable instrumentation for debug test tasks
|
||||
tasks(
|
||||
"testDebugUnitTest",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://kotlin.github.io/kotlinx-kover/
|
||||
// Run `./gradlew :app:koverHtmlReport` to get report at ./app/build/reports/kover
|
||||
// Run `./gradlew :app:koverXmlReport` to get XML report
|
||||
koverReport {
|
||||
filters {
|
||||
excludes {
|
||||
classes(
|
||||
// Exclude generated classes.
|
||||
"*_ModuleKt",
|
||||
"anvil.hint.binding.io.element.*",
|
||||
"anvil.hint.merge.*",
|
||||
"anvil.hint.multibinding.io.element.*",
|
||||
"anvil.module.*",
|
||||
"com.airbnb.android.showkase*",
|
||||
"io.element.android.libraries.designsystem.showkase.*",
|
||||
"io.element.android.x.di.DaggerAppComponent*",
|
||||
"*_Factory",
|
||||
"*_Factory_Impl",
|
||||
"*_Factory$*",
|
||||
"*_Module",
|
||||
"*_Module$*",
|
||||
"*Module_Provides*",
|
||||
"Dagger*Component*",
|
||||
"*ComposableSingletons$*",
|
||||
"*_AssistedFactory_Impl*",
|
||||
"*BuildConfig",
|
||||
// Generated by Showkase
|
||||
"*Ioelementandroid*PreviewKt$*",
|
||||
"*Ioelementandroid*PreviewKt",
|
||||
// Other
|
||||
// We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro)
|
||||
"*Node",
|
||||
"*Node$*",
|
||||
"*Presenter\$present\$*",
|
||||
// Forked from compose
|
||||
"io.element.android.libraries.designsystem.theme.components.bottomsheet.*",
|
||||
)
|
||||
annotatedBy(
|
||||
"io.element.android.libraries.designsystem.preview.PreviewsDayNight",
|
||||
"io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
defaults {
|
||||
// add reports of both 'debug' and 'release' Android build variants to default reports
|
||||
mergeWith("debug")
|
||||
mergeWith("release")
|
||||
|
||||
verify {
|
||||
onCheck = true
|
||||
// General rule: minimum code coverage.
|
||||
rule("Global minimum code coverage.") {
|
||||
isEnabled = true
|
||||
entity = kotlinx.kover.gradle.plugin.dsl.GroupingEntityType.APPLICATION
|
||||
bound {
|
||||
minValue = 65
|
||||
// Setting a max value, so that if coverage is bigger, it means that we have to change minValue.
|
||||
// For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update
|
||||
// minValue to 25 and maxValue to 35.
|
||||
maxValue = 75
|
||||
metric = kotlinx.kover.gradle.plugin.dsl.MetricType.INSTRUCTION
|
||||
aggregation = kotlinx.kover.gradle.plugin.dsl.AggregationType.COVERED_PERCENTAGE
|
||||
}
|
||||
}
|
||||
// Rule to ensure that coverage of Presenters is sufficient.
|
||||
rule("Check code coverage of presenters") {
|
||||
isEnabled = true
|
||||
entity = kotlinx.kover.gradle.plugin.dsl.GroupingEntityType.CLASS
|
||||
filters {
|
||||
includes {
|
||||
classes(
|
||||
"*Presenter",
|
||||
)
|
||||
}
|
||||
excludes {
|
||||
classes(
|
||||
"*Fake*Presenter",
|
||||
"io.element.android.appnav.loggedin.LoggedInPresenter$*",
|
||||
// Some options can't be tested at the moment
|
||||
"io.element.android.features.preferences.impl.developer.DeveloperSettingsPresenter$*",
|
||||
"*Presenter\$present\$*",
|
||||
)
|
||||
}
|
||||
}
|
||||
bound {
|
||||
minValue = 85
|
||||
metric = kotlinx.kover.gradle.plugin.dsl.MetricType.INSTRUCTION
|
||||
aggregation = kotlinx.kover.gradle.plugin.dsl.AggregationType.COVERED_PERCENTAGE
|
||||
}
|
||||
}
|
||||
// Rule to ensure that coverage of States is sufficient.
|
||||
rule("Check code coverage of states") {
|
||||
isEnabled = true
|
||||
entity = kotlinx.kover.gradle.plugin.dsl.GroupingEntityType.CLASS
|
||||
filters {
|
||||
includes {
|
||||
classes(
|
||||
"^*State$",
|
||||
)
|
||||
}
|
||||
excludes {
|
||||
classes(
|
||||
"io.element.android.appnav.root.RootNavState*",
|
||||
"io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*",
|
||||
"io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*",
|
||||
"io.element.android.libraries.matrix.api.room.RoomMembershipState*",
|
||||
"io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*",
|
||||
"io.element.android.libraries.push.impl.notifications.NotificationState*",
|
||||
"io.element.android.features.messages.impl.media.local.pdf.PdfViewerState",
|
||||
"io.element.android.features.messages.impl.media.local.LocalMediaViewState",
|
||||
"io.element.android.features.location.impl.map.MapState*",
|
||||
"io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*",
|
||||
"io.element.android.libraries.designsystem.swipe.SwipeableActionsState*",
|
||||
"io.element.android.features.messages.impl.timeline.components.ExpandableState*",
|
||||
"io.element.android.features.messages.impl.timeline.model.bubble.BubbleState*",
|
||||
"io.element.android.libraries.maplibre.compose.CameraPositionState*",
|
||||
"io.element.android.libraries.maplibre.compose.SaveableCameraPositionState",
|
||||
"io.element.android.libraries.maplibre.compose.SymbolState*",
|
||||
"io.element.android.features.ftue.api.state.*",
|
||||
"io.element.android.features.ftue.impl.welcome.state.*",
|
||||
)
|
||||
}
|
||||
}
|
||||
bound {
|
||||
minValue = 90
|
||||
metric = kotlinx.kover.gradle.plugin.dsl.MetricType.INSTRUCTION
|
||||
aggregation = kotlinx.kover.gradle.plugin.dsl.AggregationType.COVERED_PERCENTAGE
|
||||
}
|
||||
}
|
||||
// Rule to ensure that coverage of Views is sufficient (deactivated for now).
|
||||
rule("Check code coverage of views") {
|
||||
isEnabled = true
|
||||
entity = kotlinx.kover.gradle.plugin.dsl.GroupingEntityType.CLASS
|
||||
filters {
|
||||
includes {
|
||||
classes(
|
||||
"*ViewKt",
|
||||
)
|
||||
}
|
||||
}
|
||||
bound {
|
||||
// TODO Update this value, for now there are too many missing tests.
|
||||
minValue = 0
|
||||
metric = kotlinx.kover.gradle.plugin.dsl.MetricType.INSTRUCTION
|
||||
aggregation = kotlinx.kover.gradle.plugin.dsl.AggregationType.COVERED_PERCENTAGE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidReports("release") {
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
allLibrariesImpl()
|
||||
allServicesImpl()
|
||||
|
|
|
|||
176
build.gradle.kts
176
build.gradle.kts
|
|
@ -1,7 +1,5 @@
|
|||
import com.google.devtools.ksp.gradle.KspTask
|
||||
import kotlinx.kover.api.KoverTaskExtension
|
||||
import org.apache.tools.ant.taskdefs.optional.ReplaceRegExp
|
||||
import org.jetbrains.kotlin.cli.common.toBooleanLenient
|
||||
|
||||
buildscript {
|
||||
dependencies {
|
||||
|
|
@ -41,7 +39,7 @@ plugins {
|
|||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.dependencygraph)
|
||||
alias(libs.plugins.sonarqube)
|
||||
alias(libs.plugins.kover)
|
||||
alias(libs.plugins.kover) apply false
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean").configure {
|
||||
|
|
@ -164,177 +162,7 @@ allprojects {
|
|||
}
|
||||
|
||||
allprojects {
|
||||
apply(plugin = "kover")
|
||||
}
|
||||
|
||||
// https://kotlin.github.io/kotlinx-kover/
|
||||
// Run `./gradlew koverMergedHtmlReport` to get report at ./build/reports/kover
|
||||
// Run `./gradlew koverMergedReport` to also get XML report
|
||||
koverMerged {
|
||||
enable()
|
||||
|
||||
filters {
|
||||
classes {
|
||||
excludes.addAll(
|
||||
listOf(
|
||||
// Exclude generated classes.
|
||||
"*_ModuleKt",
|
||||
"anvil.hint.binding.io.element.*",
|
||||
"anvil.hint.merge.*",
|
||||
"anvil.hint.multibinding.io.element.*",
|
||||
"anvil.module.*",
|
||||
"com.airbnb.android.showkase*",
|
||||
"io.element.android.libraries.designsystem.showkase.*",
|
||||
"io.element.android.x.di.DaggerAppComponent*",
|
||||
"*_Factory",
|
||||
"*_Factory_Impl",
|
||||
"*_Factory$*",
|
||||
"*_Module",
|
||||
"*_Module$*",
|
||||
"*Module_Provides*",
|
||||
"Dagger*Component*",
|
||||
"*ComposableSingletons$*",
|
||||
"*_AssistedFactory_Impl*",
|
||||
"*BuildConfig",
|
||||
// Generated by Showkase
|
||||
"*Ioelementandroid*PreviewKt$*",
|
||||
"*Ioelementandroid*PreviewKt",
|
||||
// Other
|
||||
// We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro)
|
||||
"*Node",
|
||||
"*Node$*",
|
||||
// Exclude `:libraries:matrix:impl` module, it contains only wrappers to access the Rust Matrix SDK api, so it is not really relevant to unit test it: there is no logic to test.
|
||||
"io.element.android.libraries.matrix.impl.*",
|
||||
"*Presenter\$present\$*",
|
||||
// Forked from compose
|
||||
"io.element.android.libraries.designsystem.theme.components.bottomsheet.*",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
annotations {
|
||||
excludes.addAll(
|
||||
listOf(
|
||||
"*Preview",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
projects {
|
||||
excludes.addAll(
|
||||
listOf(
|
||||
":anvilannotations",
|
||||
":anvilcodegen",
|
||||
":samples:minimal",
|
||||
":tests:testutils",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Run ./gradlew koverMergedVerify to check the rules.
|
||||
verify {
|
||||
// Does not seems to work, so also run the task manually on the workflow.
|
||||
onCheck.set(true)
|
||||
// General rule: minimum code coverage.
|
||||
rule {
|
||||
name = "Global minimum code coverage."
|
||||
target = kotlinx.kover.api.VerificationTarget.ALL
|
||||
bound {
|
||||
minValue = 65
|
||||
// Setting a max value, so that if coverage is bigger, it means that we have to change minValue.
|
||||
// For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update
|
||||
// minValue to 25 and maxValue to 35.
|
||||
maxValue = 75
|
||||
counter = kotlinx.kover.api.CounterType.INSTRUCTION
|
||||
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
|
||||
}
|
||||
}
|
||||
// Rule to ensure that coverage of Presenters is sufficient.
|
||||
rule {
|
||||
name = "Check code coverage of presenters"
|
||||
target = kotlinx.kover.api.VerificationTarget.CLASS
|
||||
overrideClassFilter {
|
||||
includes += "*Presenter"
|
||||
excludes += "*Fake*Presenter"
|
||||
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
|
||||
// Some options can't be tested at the moment
|
||||
excludes += "io.element.android.features.preferences.impl.developer.DeveloperSettingsPresenter$*"
|
||||
excludes += "*Presenter\$present\$*"
|
||||
}
|
||||
bound {
|
||||
minValue = 85
|
||||
counter = kotlinx.kover.api.CounterType.INSTRUCTION
|
||||
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
|
||||
}
|
||||
}
|
||||
// Rule to ensure that coverage of States is sufficient.
|
||||
rule {
|
||||
name = "Check code coverage of states"
|
||||
target = kotlinx.kover.api.VerificationTarget.CLASS
|
||||
overrideClassFilter {
|
||||
includes += "^*State$"
|
||||
excludes += "io.element.android.appnav.root.RootNavState*"
|
||||
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*"
|
||||
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*"
|
||||
excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*"
|
||||
excludes += "io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*"
|
||||
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
|
||||
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
|
||||
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
|
||||
excludes += "io.element.android.features.location.impl.map.MapState*"
|
||||
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*"
|
||||
excludes += "io.element.android.libraries.designsystem.swipe.SwipeableActionsState*"
|
||||
excludes += "io.element.android.features.messages.impl.timeline.components.ExpandableState*"
|
||||
excludes += "io.element.android.features.messages.impl.timeline.model.bubble.BubbleState*"
|
||||
excludes += "io.element.android.libraries.maplibre.compose.CameraPositionState*"
|
||||
excludes += "io.element.android.libraries.maplibre.compose.SaveableCameraPositionState"
|
||||
excludes += "io.element.android.libraries.maplibre.compose.SymbolState*"
|
||||
excludes += "io.element.android.features.ftue.api.state.*"
|
||||
excludes += "io.element.android.features.ftue.impl.welcome.state.*"
|
||||
}
|
||||
bound {
|
||||
minValue = 90
|
||||
counter = kotlinx.kover.api.CounterType.INSTRUCTION
|
||||
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
|
||||
}
|
||||
}
|
||||
// Rule to ensure that coverage of Views is sufficient (deactivated for now).
|
||||
rule {
|
||||
name = "Check code coverage of views"
|
||||
target = kotlinx.kover.api.VerificationTarget.CLASS
|
||||
overrideClassFilter {
|
||||
includes += "*ViewKt"
|
||||
}
|
||||
bound {
|
||||
// TODO Update this value, for now there are too many missing tests.
|
||||
minValue = 0
|
||||
counter = kotlinx.kover.api.CounterType.INSTRUCTION
|
||||
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When running on the CI, run only debug test variants
|
||||
val ciBuildProperty = "ci-build"
|
||||
val isCiBuild = if (project.hasProperty(ciBuildProperty)) {
|
||||
val raw = project.property(ciBuildProperty) as? String
|
||||
raw?.toBooleanLenient() == true || raw?.toIntOrNull() == 1
|
||||
} else {
|
||||
false
|
||||
}
|
||||
if (isCiBuild) {
|
||||
allprojects {
|
||||
afterEvaluate {
|
||||
tasks.withType<Test>().configureEach {
|
||||
extensions.configure<KoverTaskExtension> {
|
||||
val enabled = name.contains("debug", ignoreCase = true)
|
||||
isDisabled.set(!enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
apply(plugin = "org.jetbrains.kotlinx.kover")
|
||||
}
|
||||
|
||||
// Register quality check tasks.
|
||||
|
|
|
|||
1
changelog.d/1782.misc
Normal file
1
changelog.d/1782.misc
Normal file
|
|
@ -0,0 +1 @@
|
|||
Migrate to Kover 0.7.X
|
||||
1
changelog.d/2156.bugfix
Normal file
1
changelog.d/2156.bugfix
Normal file
|
|
@ -0,0 +1 @@
|
|||
Improve rendering of voice messages in the timeline in large displays
|
||||
|
|
@ -344,26 +344,26 @@ implementation of our interfaces. Mocking can be used to mock Android framework
|
|||
[kover](https://github.com/Kotlin/kotlinx-kover) is used to compute code coverage. Only have unit tests can produce code coverage result. Running Maestro does
|
||||
not participate to the code coverage results.
|
||||
|
||||
Kover configuration is defined in the main [build.gradle.kts](../build.gradle.kts) file.
|
||||
Kover configuration is defined in the app [build.gradle.kts](../app/build.gradle.kts) file.
|
||||
|
||||
To compute the code coverage, run:
|
||||
|
||||
```bash
|
||||
./gradlew koverMergedReport
|
||||
./gradlew :app:koverHtmlReport
|
||||
```
|
||||
|
||||
and open the Html report: [../build/reports/kover/merged/html/index.html](../build/reports/kover/merged/html/index.html)
|
||||
and open the Html report: [../app/build/reports/kover/html/index.html](../app/build/reports/kover/html/index.html)
|
||||
|
||||
To ensure that the code coverage threshold are OK, you can run
|
||||
|
||||
```bash
|
||||
./gradlew koverMergedVerify
|
||||
./gradlew :app:koverVerify
|
||||
```
|
||||
|
||||
Note that the CI performs this check on every pull requests.
|
||||
|
||||
Also, if the rule `Global minimum code coverage.` is in error because code coverage is `> maxValue`, `minValue` and `maxValue` can be updated for this rule in
|
||||
the file [build.gradle.kts](../build.gradle.kts) (you will see further instructions there).
|
||||
the file [build.gradle.kts](../app/build.gradle.kts) (you will see further instructions there).
|
||||
|
||||
### Other points
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,6 @@ dependencies {
|
|||
testImplementation(projects.libraries.mediapickers.test)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.textcomposer.test)
|
||||
testImplementation(projects.libraries.voicerecorder.test)
|
||||
testImplementation(projects.libraries.mediaplayer.test)
|
||||
testImplementation(projects.libraries.mediaviewer.test)
|
||||
|
|
|
|||
|
|
@ -110,9 +110,7 @@ fun TimelineItemVoiceView(
|
|||
showCursor = state.showCursor,
|
||||
playbackProgress = state.progress,
|
||||
waveform = content.waveform,
|
||||
modifier = Modifier
|
||||
.height(34.dp)
|
||||
.weight(1f),
|
||||
modifier = Modifier.height(34.dp),
|
||||
seekEnabled = !context.isScreenReaderEnabled(),
|
||||
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
|
|||
dependencycheck = "org.owasp.dependencycheck:9.0.8"
|
||||
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
|
||||
paparazzi = "app.cash.paparazzi:1.3.1"
|
||||
kover = "org.jetbrains.kotlinx.kover:0.6.1"
|
||||
kover = "org.jetbrains.kotlinx.kover:0.7.5"
|
||||
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||
firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }
|
||||
knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" }
|
||||
|
|
|
|||
1
libraries/coroutines/.gitignore
vendored
1
libraries/coroutines/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
/build
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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("java-library")
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
}
|
||||
|
|
@ -23,23 +23,22 @@ import androidx.compose.ui.graphics.Brush
|
|||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlin.math.max
|
||||
|
||||
fun DrawScope.drawWaveform(
|
||||
waveformData: ImmutableList<Float>,
|
||||
canvasSize: DpSize,
|
||||
canvasSizePx: Size,
|
||||
brush: Brush,
|
||||
minimumGraphAmplitude: Float = 2F,
|
||||
lineWidth: Dp = 2.dp,
|
||||
linePadding: Dp = 2.dp,
|
||||
) {
|
||||
val centerY = canvasSize.height.toPx() / 2
|
||||
val centerY = canvasSizePx.height / 2
|
||||
val cornerRadius = lineWidth / 2
|
||||
waveformData.forEachIndexed { index, amplitude ->
|
||||
val drawingAmplitude = max(minimumGraphAmplitude, amplitude * (canvasSize.height.toPx() - 2))
|
||||
val drawingAmplitude = max(minimumGraphAmplitude, amplitude * (canvasSizePx.height - 2))
|
||||
drawRoundRect(
|
||||
brush = brush,
|
||||
topLeft = Offset(
|
||||
|
|
|
|||
|
|
@ -40,12 +40,13 @@ import androidx.compose.ui.graphics.drawscope.Fill
|
|||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.RequestDisallowInterceptTouchEvent
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
|
@ -58,7 +59,7 @@ private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
|
|||
*
|
||||
* @param playbackProgress The current playback progress, between 0 and 1.
|
||||
* @param showCursor Whether to show the cursor or not.
|
||||
* @param waveform The waveform to display. Use [FakeWaveformFactory] to generate a fake waveform.
|
||||
* @param waveform The waveform to display. Use [createFakeWaveform] to generate a fake waveform.
|
||||
* @param onSeek Callback when the user seeks the waveform. Called with a value between 0 and 1.
|
||||
* @param modifier The modifier to be applied to the view.
|
||||
* @param seekEnabled Whether the user can seek the waveform or not.
|
||||
|
|
@ -103,6 +104,11 @@ fun WaveformPlaybackView(
|
|||
}
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val waveformWidthPx by remember {
|
||||
derivedStateOf { with(density) { normalizedWaveformData.size * (lineWidth + linePadding).roundToPx().toFloat() } }
|
||||
}
|
||||
|
||||
val requestDisallowInterceptTouchEvent = remember { RequestDisallowInterceptTouchEvent() }
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
|
|
@ -110,19 +116,18 @@ fun WaveformPlaybackView(
|
|||
.graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA)
|
||||
.let {
|
||||
if (!seekEnabled) return@let it
|
||||
|
||||
it.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { e ->
|
||||
return@pointerInteropFilter when (e.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
if (e.x in 0F..canvasSizePx.width) {
|
||||
if (e.x in 0F..waveformWidthPx) {
|
||||
requestDisallowInterceptTouchEvent.invoke(true)
|
||||
seekProgress.value = e.x / canvasSizePx.width
|
||||
seekProgress.value = e.x / waveformWidthPx
|
||||
true
|
||||
} else false
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (e.x in 0F..canvasSizePx.width) {
|
||||
seekProgress.value = e.x / canvasSizePx.width
|
||||
if (e.x in 0F..waveformWidthPx) {
|
||||
seekProgress.value = e.x / waveformWidthPx
|
||||
}
|
||||
true
|
||||
}
|
||||
|
|
@ -140,11 +145,11 @@ fun WaveformPlaybackView(
|
|||
) {
|
||||
canvasSize = size.toDpSize()
|
||||
canvasSizePx = size
|
||||
val centerY = canvasSize.height.toPx() / 2
|
||||
val cornerRadius = lineWidth / 2
|
||||
// Calculate the size of the waveform by summing the width of all the lines and paddings
|
||||
drawWaveform(
|
||||
waveformData = normalizedWaveformData,
|
||||
canvasSize = canvasSize,
|
||||
canvasSizePx = canvasSizePx,
|
||||
brush = brush,
|
||||
lineWidth = lineWidth,
|
||||
linePadding = linePadding
|
||||
|
|
@ -152,8 +157,8 @@ fun WaveformPlaybackView(
|
|||
drawRect(
|
||||
brush = progressBrush,
|
||||
size = Size(
|
||||
width = progressAnimated.value * canvasSize.width.toPx(),
|
||||
height = canvasSize.height.toPx()
|
||||
width = progressAnimated.value * waveformWidthPx,
|
||||
height = canvasSizePx.height
|
||||
),
|
||||
blendMode = BlendMode.SrcAtop
|
||||
)
|
||||
|
|
@ -161,12 +166,12 @@ fun WaveformPlaybackView(
|
|||
drawRoundRect(
|
||||
brush = cursorBrush,
|
||||
topLeft = Offset(
|
||||
x = progressAnimated.value * canvasSize.width.toPx(),
|
||||
y = centerY - (canvasSize.height.toPx() - 2) / 2
|
||||
x = progressAnimated.value * waveformWidthPx,
|
||||
y = 1f
|
||||
),
|
||||
size = Size(
|
||||
width = lineWidth.toPx(),
|
||||
height = canvasSize.height.toPx() - 2
|
||||
height = canvasSizePx.height - 2
|
||||
),
|
||||
cornerRadius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx()),
|
||||
style = Fill
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
|
@ -66,7 +67,6 @@ fun LiveWaveformView(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
Box(contentAlignment = Alignment.CenterEnd,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -79,11 +79,12 @@ fun LiveWaveformView(
|
|||
.graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA)
|
||||
.then(modifier)
|
||||
) {
|
||||
canvasSize = DpSize(Dp(min(waveformWidth, parentWidth.toFloat())), size.height.toDp())
|
||||
val width = min(waveformWidth, parentWidth.toFloat())
|
||||
canvasSize = DpSize(width.dp, size.height.toDp())
|
||||
val countThatFitsWidth = (parentWidth.toFloat() / (lineWidth.toPx() + linePadding.toPx())).toInt()
|
||||
drawWaveform(
|
||||
waveformData = levels.takeLast(countThatFitsWidth).toPersistentList(),
|
||||
canvasSize = canvasSize,
|
||||
canvasSizePx = Size(canvasSize.width.toPx(), size.height),
|
||||
brush = brush,
|
||||
lineWidth = lineWidth,
|
||||
linePadding = linePadding,
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.textcomposer.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.textcomposer.impl)
|
||||
implementation(projects.tests.testutils)
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:298c750f283995af2552ac72f86fe4747287a781b8c25e890616efd08b4ae54e
|
||||
size 45436
|
||||
oid sha256:070f5cc86ef672c0999e0c926bcdee09a4d8b8c70d89152a61abfe34e2289a66
|
||||
size 44929
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d6871183e7bba8fc9574dc838ef10626a7d573d21cea6d0a1a4219ac928c8697
|
||||
size 44211
|
||||
oid sha256:b8b86b0ff5d34a8f9223cd4a33a95494321540102ed0515d19437911abe387ac
|
||||
size 43742
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bc14498cb4178dbf6a103159eabe6e0c7f4458f2e10350eacd58a9c0beefff10
|
||||
size 10056
|
||||
oid sha256:0df1912bae9bdfb9778bfe9c8cc10c2f12a09d6b26f0d379cbec3ce5fac0a0cf
|
||||
size 10050
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9df0b76dd3d46022243b03d5c5f1accabf2345b9412d2cf1f9d3e6e7d5bb18f4
|
||||
size 9565
|
||||
oid sha256:6d2949f01b23ffca6eac0eb6a21768caf27a5fc8cbca501ac691160276d32636
|
||||
size 9626
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:260e882a9a068b5a81a251cfd20c6c99473fc7d905551f1e8de12d0d6165538d
|
||||
size 9919
|
||||
oid sha256:0facb6fd321f293b6856724bbae2f4f686f7a87ce6229e17202b1d283b6c6f0e
|
||||
size 9921
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9e0772d6c4f9b994d2b32de900b80faccb5598774db46fc161009a4774d6efd2
|
||||
size 5531
|
||||
oid sha256:2897384e35af54220b8f2555a376c20f289173448a5afb027fedd82a5b868966
|
||||
size 5783
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:438d24d36310528695e0f79d9a515a599ae27fa028c2290477a0df622240ab87
|
||||
size 6077
|
||||
oid sha256:a765606c839ce01c256aaf334e2db3bb9bb05616b5f817ea5c91b40ba2443e63
|
||||
size 6021
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:52ae85fdd9eeb0e3d81336c38147d8251079e2ae61364ca7f7cd573676dc2c57
|
||||
size 7085
|
||||
oid sha256:748b45d63f0614fe78e569474a4b18bb53656a023518b382d7143b8f6f95c8e9
|
||||
size 7094
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a0f92bf5702aa5c05be66bfd03f2a4437385abe20e8d397c21aaa329018f38ee
|
||||
size 6617
|
||||
oid sha256:db4dd789fa9205a59551ee15e2f08d7a37711257c5a35df4169aed8e057f87e1
|
||||
size 6729
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:212d6a2efc1d6464086a4fbf60922efc6285705d0365752e5d264c1b19c1f97c
|
||||
size 7063
|
||||
oid sha256:4845646cc19c7077373de85485a5be098d1cf1e6ab98ac80dd32de683d3595cc
|
||||
size 6686
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:219a6f8e4032493f96a4df0e1072ad8ddfcd5d77c15878f26c4ffd462417b2d0
|
||||
size 7303
|
||||
oid sha256:fd11dd439776c390f18614152a34b0845a2ab99ff6c9e482cd50437c85a22b31
|
||||
size 6782
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1dfdaca0c643c81a281658b4cee4d0e938d542a57964c3831496d04db543da45
|
||||
size 9827
|
||||
oid sha256:842aac7dfd6cf1a7f0100583508bfcf2d2cf636a3b77d175252d90260868f985
|
||||
size 9822
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f4b748360d95b3898a471c38ca6f1440a6b83590ebd297abdeb59539e08f25a0
|
||||
size 9535
|
||||
oid sha256:a87b958c2d5b0bc4826339ecd33eb5bbe68326b8dbe2cff8242796d031323ac1
|
||||
size 9585
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:01a918a5cbe544f7f0536373b94e41673fa0dfb70a1616aebdab385b9f5e6b74
|
||||
size 9635
|
||||
oid sha256:e7ff4f4b2f76c58d94d8f31f1b86a0679d786037efa4e0077f2c7eb59bd14901
|
||||
size 9640
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c4578011963fa9d2d0a6c5e7278cabf301450e8f2b350ede3cf0b7a175657dcc
|
||||
size 5519
|
||||
oid sha256:d2f8cb28bc614e73a70b5192c4c13a1ec580d0aeafa89b096443b514691d26ab
|
||||
size 5749
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b3c5a7bfa1564787ee64d967e77ca84793f365e3a6d77beeba19106192eb26a3
|
||||
size 6011
|
||||
oid sha256:7d2a6926520c23d086b25bd05231943a5f094d3afe8705027b4266e4d17393a9
|
||||
size 5966
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3817e348ba2775528e3805ad6f5eed044f82b241f73692180c0638c95fb79fa2
|
||||
size 7076
|
||||
oid sha256:c669c896158bc19ae916b0e6f1d12f42fe86506323c5bea55e0dd12d9d077153
|
||||
size 7090
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7aabc26dcb237a1d87c8f888812643e472cff9f082a5fb18d800698762becb63
|
||||
size 6636
|
||||
oid sha256:457a91cca308177ac9fb46479c711e4c0863f212e0d20b82008db34a1fb6bb7a
|
||||
size 6732
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:11a48af25f2b2f656a3e6ce323d2a4b392cf180555f06423c2ecc0044ef9de22
|
||||
size 6901
|
||||
oid sha256:a119b19c49aa8fc5aa092cf3a195dbd1ba48e858b7ecf4c9a922624dde9e3213
|
||||
size 6590
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ce011eeb89741dcdb9c98edfd3f0daf4268e0e02b013caf12fec9b4a33d20b05
|
||||
size 7132
|
||||
oid sha256:2154ae0f36cb0b220e8f3b644a16d9a4a61f143d5edcb62f3c962a8136cce577
|
||||
size 6670
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dbe3214d0b8c497f563957c9df870aa7b6cbf981e174a7f9481cc1555af50533
|
||||
size 10247
|
||||
oid sha256:62b94ac085616506e2433b451259d209751fadd43d4442dacc49bf479329dc42
|
||||
size 10227
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:876ac7c1ac9f3a188bd4ebcc4faef6fd363e166b1ec3d5a844872f7a0cf676aa
|
||||
size 9969
|
||||
oid sha256:db18e528fa11b3800dfb48b68b5618edcc8d019320db68e4be5bcf5ad7d8f736
|
||||
size 9948
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1040e8767f3ce29d40842cb054dea64dc0d355b91ae86b1d25280b9308cdbda7
|
||||
size 24517
|
||||
oid sha256:e03637c50b40c8509dac9ff7c06077183945e024ee2a3d6ead14fca5cf79353f
|
||||
size 24522
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:162a6b2a725ecfda754f8fe8bc06da0bd6b7efd7eb217a2e77b1ece57f89d5d6
|
||||
size 22936
|
||||
oid sha256:6a6c39ffde0c5db010ec4df376b4dc89949d4e023afbd5771a0880144c478b6f
|
||||
size 22920
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6ae5e1ef943fb5f0e0badf41d4ce6317117ab15c8ce8d1bb3a1712260f7f637b
|
||||
size 28703
|
||||
oid sha256:a3559b8bbdf1127ab39b884d472c5690ae51d36811e5b27a0e6140aa58480977
|
||||
size 28661
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a498224c5657212ae19d2854c687a6057735f12521c2a97db9f5754fb23ecfd
|
||||
size 27828
|
||||
oid sha256:c144bbc058d1dfe3b75640e017c8e91f455ad86075ef415fa1e25d69d46899ee
|
||||
size 27816
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue