Skip to content

Commit fea5dcc

Browse files
author
mcbaumwolle
committed
feat(account): support PNG avatars with OpenDocument picker
1 parent bbde046 commit fea5dcc

File tree

5 files changed

+124
-69
lines changed

5 files changed

+124
-69
lines changed

feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/data/datasource/LocalAvatarImageDataSource.kt

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,59 @@ import kotlin.time.Clock
55
import kotlin.time.ExperimentalTime
66
import net.thunderbird.core.file.DirectoryProvider
77
import net.thunderbird.core.file.FileManager
8+
import net.thunderbird.core.file.MimeType
9+
import net.thunderbird.core.file.MimeTypeResolver
810
import net.thunderbird.feature.account.AccountId
911
import net.thunderbird.feature.account.avatar.data.AvatarDataContract.DataSource.LocalAvatarImage
1012

11-
/**
12-
* Local data source implementation for managing avatar images.
13-
*
14-
* Uses [DirectoryProvider] to resolve the app's files directory and stores
15-
* avatar images under `${LocalAvatarImage.DIRECTORY_NAME}`.
16-
*
17-
* @param fileManager The [FileManager] for file operations.
18-
* @param directoryProvider The [DirectoryProvider] to get app directories.
19-
*/
2013
@OptIn(ExperimentalTime::class)
2114
internal class LocalAvatarImageDataSource(
2215
private val fileManager: FileManager,
2316
private val directoryProvider: DirectoryProvider,
17+
private val mimeTypeResolver: MimeTypeResolver,
2418
private val clock: Clock,
2519
) : LocalAvatarImage {
2620

2721
override suspend fun update(id: AccountId, imageUri: Uri): Uri {
28-
val avatarImageUri = getAvatarImageUri(id)
22+
val mimeType = mimeTypeResolver.getMimeType(imageUri)
23+
val targetExtension = if (mimeType == MimeType.PNG) EXTENSION_PNG else EXTENSION_JPG
24+
25+
delete(id)
26+
27+
val avatarImageUri = getAvatarImageUri(id, targetExtension)
2928

3029
fileManager.copy(imageUri, avatarImageUri)
3130

3231
return avatarImageUri.buildUpon()
33-
.clearQuery()
34-
.appendQueryParameter("v", clock.now().toEpochMilliseconds().toString())
32+
.appendQueryParameter(PARAMETER_VERSION, clock.now().toEpochMilliseconds().toString())
3533
.build()
3634
}
3735

3836
override suspend fun delete(id: AccountId) {
39-
val avatarImageUri = getAvatarImageUri(id)
40-
fileManager.delete(avatarImageUri)
37+
val now = clock.now().toEpochMilliseconds().toString()
38+
39+
fileManager.delete(
40+
getAvatarImageUri(id, EXTENSION_JPG).buildUpon()
41+
.appendQueryParameter(PARAMETER_VERSION, now)
42+
.build(),
43+
)
44+
fileManager.delete(
45+
getAvatarImageUri(id, EXTENSION_PNG).buildUpon()
46+
.appendQueryParameter(PARAMETER_VERSION, now)
47+
.build(),
48+
)
4149
}
4250

43-
private suspend fun getAvatarImageUri(id: AccountId): Uri = getAvatarDirUri().buildUpon()
44-
.appendPath("$id.$AVATAR_IMAGE_FILE_EXTENSION")
45-
.build()
46-
47-
private suspend fun getAvatarDirUri(): Uri {
51+
private fun getAvatarImageUri(id: AccountId, extension: String): Uri {
4852
return directoryProvider.getFilesDir().buildUpon()
4953
.appendPath(LocalAvatarImage.DIRECTORY_NAME)
54+
.appendPath("$id.$extension")
5055
.build()
51-
.also { fileManager.createDirectories(it) }
5256
}
5357

54-
private companion object {
55-
const val AVATAR_IMAGE_FILE_EXTENSION = "jpg"
58+
companion object {
59+
private const val EXTENSION_JPG = "jpg"
60+
private const val EXTENSION_PNG = "png"
61+
private const val PARAMETER_VERSION = "v"
5662
}
5763
}

feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/di/FeatureAccountAvatarModule.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ val featureAccountAvatarModule = module {
1313
LocalAvatarImageDataSource(
1414
fileManager = get(),
1515
directoryProvider = get(),
16-
clock = get(),
16+
mimeTypeResolver = get(),
17+
clock = get(), // fetch the app's global 'Clock'
1718
)
1819
}
1920

Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
package net.thunderbird.feature.account.avatar.data.datasource
22

33
import assertk.assertThat
4+
import assertk.assertions.contains
45
import assertk.assertions.isEqualTo
5-
import assertk.assertions.isNotEqualTo
6-
import assertk.assertions.isNotNull
7-
import assertk.assertions.isNull
6+
import com.eygraber.uri.Uri
87
import com.eygraber.uri.toKmpUri
98
import kotlin.test.BeforeTest
109
import kotlin.test.Test
11-
import kotlin.time.Duration.Companion.milliseconds
1210
import kotlin.time.ExperimentalTime
1311
import kotlin.time.Instant
1412
import kotlinx.coroutines.test.runTest
1513
import net.thunderbird.core.file.DirectoryProvider
14+
import net.thunderbird.core.file.FileManager
15+
import net.thunderbird.core.file.FileOperationError
16+
import net.thunderbird.core.file.MimeType
17+
import net.thunderbird.core.file.MimeTypeResolver
18+
import net.thunderbird.core.outcome.Outcome
1619
import net.thunderbird.core.testing.TestClock
1720
import net.thunderbird.feature.account.AccountIdFactory
1821
import net.thunderbird.feature.account.avatar.data.AvatarDataContract
@@ -27,77 +30,121 @@ class LocalAvatarImageDataSourceTest {
2730
val folder = TemporaryFolder()
2831

2932
private lateinit var directoryProvider: DirectoryProvider
30-
private lateinit var fileManager: CapturingFileManager
33+
private lateinit var fileManager: SpyFileManager
34+
private lateinit var mimeTypeResolver: FakeMimeTypeResolver
3135
private lateinit var clock: TestClock
3236
private lateinit var testSubject: LocalAvatarImageDataSource
3337

3438
@BeforeTest
3539
fun setUp() {
3640
val appDir = folder.newFolder("app")
3741
directoryProvider = FakeDirectoryProvider(appDir.absolutePath.toKmpUri())
38-
fileManager = CapturingFileManager()
42+
fileManager = SpyFileManager()
43+
mimeTypeResolver = FakeMimeTypeResolver()
3944
clock = TestClock(Instant.fromEpochMilliseconds(1_000))
40-
testSubject = LocalAvatarImageDataSource(fileManager, directoryProvider, clock)
45+
46+
testSubject = LocalAvatarImageDataSource(
47+
fileManager,
48+
directoryProvider,
49+
mimeTypeResolver,
50+
clock,
51+
)
4152
}
4253

4354
@Test
44-
fun `update should copy image to expected path and return versioned destination uri`() = runTest {
45-
// Arrange
55+
fun `update with JPG should copy image to JPG path and clean up old files`() = runTest {
4656
val accountId = AccountIdFactory.create()
4757
val source = "file:///external/picked/image.jpg".toKmpUri()
48-
val expectedDir = directoryProvider.getFilesDir().buildUpon()
49-
.appendPath(AvatarDataContract.DataSource.LocalAvatarImage.DIRECTORY_NAME)
50-
.build()
58+
val expectedDir = getAvatarDir()
5159
val expectedDest = expectedDir.buildUpon().appendPath("$accountId.jpg").build()
52-
val expectedVersioned = expectedDest.buildUpon().appendQueryParameter("v", "1000").build()
60+
val expectedVersioned = expectedDest.buildUpon()
61+
.appendQueryParameter("v", "1000")
62+
.build()
63+
64+
mimeTypeResolver.mimeTypeToReturn = MimeType.JPEG
5365

54-
// Act
5566
val returned = testSubject.update(accountId, source)
5667

57-
// Assert
5868
assertThat(returned).isEqualTo(expectedVersioned)
59-
assertThat(fileManager.lastCreatedDir).isEqualTo(expectedDir)
6069
assertThat(fileManager.lastCopySource).isEqualTo(source)
6170
assertThat(fileManager.lastCopyDestination).isEqualTo(expectedDest)
62-
assertThat(fileManager.lastDeleted).isNull()
71+
72+
val expectedPngDel = expectedDir.buildUpon()
73+
.appendPath("$accountId.png")
74+
.appendQueryParameter("v", "1000")
75+
.build()
76+
77+
assertThat(fileManager.deletedPaths).contains(expectedPngDel)
78+
assertThat(fileManager.deletedPaths).contains(expectedVersioned)
6379
}
6480

6581
@Test
66-
fun `successive updates should return different URIs`() = runTest {
67-
// Arrange
82+
fun `update with PNG should copy image to PNG path`() = runTest {
6883
val accountId = AccountIdFactory.create()
69-
val source = "file:///external/picked/image.jpg".toKmpUri()
84+
val source = "file:///external/picked/photo.png".toKmpUri()
85+
val expectedDir = getAvatarDir()
86+
val expectedDest = expectedDir.buildUpon().appendPath("$accountId.png").build()
87+
val expectedVersioned = expectedDest.buildUpon()
88+
.appendQueryParameter("v", "1000")
89+
.build()
7090

71-
// Act
72-
val uri1 = testSubject.update(accountId, source)
73-
clock.advanceTimeBy(1.milliseconds)
74-
val uri2 = testSubject.update(accountId, source)
91+
mimeTypeResolver.mimeTypeToReturn = MimeType.PNG
92+
93+
val returned = testSubject.update(accountId, source)
7594

76-
// Assert
77-
assertThat(uri1).isNotEqualTo(uri2)
78-
// Base paths should be the same
79-
assertThat(uri1.buildUpon().clearQuery().build()).isEqualTo(uri2.buildUpon().clearQuery().build())
95+
assertThat(returned).isEqualTo(expectedVersioned)
96+
assertThat(fileManager.lastCopySource).isEqualTo(source)
97+
assertThat(fileManager.lastCopyDestination).isEqualTo(expectedDest)
8098
}
8199

82100
@Test
83-
fun `delete should remove expected avatar path`() = runTest {
84-
// Arrange
101+
fun `delete should remove both JPG and PNG paths`() = runTest {
85102
val accountId = AccountIdFactory.create()
86-
val expectedDir = directoryProvider.getFilesDir().buildUpon()
87-
.appendPath(AvatarDataContract.DataSource.LocalAvatarImage.DIRECTORY_NAME)
103+
val expectedDir = getAvatarDir()
104+
val jpgPath = expectedDir.buildUpon()
105+
.appendPath("$accountId.jpg")
106+
.appendQueryParameter("v", "1000")
107+
.build()
108+
val pngPath = expectedDir.buildUpon()
109+
.appendPath("$accountId.png")
110+
.appendQueryParameter("v", "1000")
88111
.build()
89-
val expectedDest = expectedDir.buildUpon().appendPath("$accountId.jpg").build()
90112

91-
// Act
92113
testSubject.delete(accountId)
93114

94-
// Assert
95-
assertThat(fileManager.lastDeleted).isEqualTo(expectedDest)
96-
// No copy on delete
97-
assertThat(fileManager.lastCopySource).isNull()
98-
assertThat(fileManager.lastCopyDestination).isNull()
99-
// Directory creation occurs when computing path even in delete(), due to getAvatarDirUri()
100-
assertThat(fileManager.lastCreatedDir).isNotNull()
101-
assertThat(fileManager.lastCreatedDir).isEqualTo(expectedDir)
115+
assertThat(fileManager.deletedPaths).contains(jpgPath)
116+
assertThat(fileManager.deletedPaths).contains(pngPath)
117+
}
118+
119+
private fun getAvatarDir(): Uri {
120+
return directoryProvider.getFilesDir().buildUpon()
121+
.appendPath(AvatarDataContract.DataSource.LocalAvatarImage.DIRECTORY_NAME)
122+
.build()
123+
}
124+
125+
class FakeMimeTypeResolver : MimeTypeResolver {
126+
var mimeTypeToReturn: MimeType? = null
127+
override fun getMimeType(uri: Uri): MimeType? = mimeTypeToReturn
128+
}
129+
130+
class SpyFileManager : FileManager {
131+
var lastCopySource: Uri? = null
132+
var lastCopyDestination: Uri? = null
133+
val deletedPaths = mutableListOf<Uri>()
134+
135+
override suspend fun copy(sourceUri: Uri, destinationUri: Uri): Outcome<Unit, FileOperationError> {
136+
lastCopySource = sourceUri
137+
lastCopyDestination = destinationUri
138+
return Outcome.Success(Unit)
139+
}
140+
141+
override suspend fun delete(uri: Uri): Outcome<Unit, FileOperationError> {
142+
deletedPaths.add(uri)
143+
return Outcome.Success(Unit)
144+
}
145+
146+
override suspend fun createDirectories(uri: Uri): Outcome<Unit, FileOperationError> {
147+
return Outcome.Success(Unit)
148+
}
102149
}
103150
}

feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateAvatarImage.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ internal class UpdateAvatarImage(
2222
): Outcome<Avatar.Image, AccountSettingError> {
2323
val mimeType = mimeTypeResolver.getMimeType(imageUri)
2424

25-
if (mimeType == null || mimeType != MimeType.JPEG) {
25+
// Check for both JPEG and PNG
26+
if (mimeType == null || (mimeType != MimeType.JPEG && mimeType != MimeType.PNG)) {
2627
return Outcome.Failure(
2728
AccountSettingError.UnsupportedFormat(
28-
message = "Only JPEG images are supported. Found: ${mimeType ?: "unknown"}",
29+
message = "Only JPEG and PNG images are supported. Found: ${mimeType ?: "unknown"}",
2930
),
3031
)
3132
}

feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsScreen.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ internal fun GeneralSettingsScreen(
2525
provider: SettingViewProvider = koinInject(),
2626
builder: SettingsBuilder = koinInject(),
2727
) {
28-
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
28+
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
2929
if (uri != null) {
3030
viewModel.event(Event.OnAvatarImagePicked(uri.toKmpUri()))
3131
}
@@ -35,7 +35,7 @@ internal fun GeneralSettingsScreen(
3535
when (effect) {
3636
is Effect.NavigateBack -> onBack()
3737
is Effect.OpenAvatarImagePicker -> {
38-
imagePicker.launch("image/jpeg")
38+
imagePicker.launch(arrayOf("image/jpeg", "image/png"))
3939
}
4040
}
4141
}

0 commit comments

Comments
 (0)