Skip to content

Commit 2e8ceec

Browse files
committed
Albums functionality
1 parent e8d3cf5 commit 2e8ceec

File tree

67 files changed

+6642
-79
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+6642
-79
lines changed
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2025 TSI-mc <[email protected]>
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
package com.nmc.android
9+
10+
import android.content.Context
11+
import android.content.res.Configuration
12+
import android.util.DisplayMetrics
13+
import androidx.test.core.app.ApplicationProvider
14+
import androidx.test.ext.junit.runners.AndroidJUnit4
15+
import com.owncloud.android.R
16+
import junit.framework.TestCase.assertEquals
17+
import org.junit.Test
18+
import org.junit.runner.RunWith
19+
import java.util.Locale
20+
21+
/**
22+
* Test class to verify the strings and dimens customized in this branch PR for NMC
23+
*/
24+
@RunWith(AndroidJUnit4::class)
25+
class AlbumsResourceTest {
26+
27+
private val baseContext = ApplicationProvider.getApplicationContext<Context>()
28+
29+
private val localizedStringMap = mapOf(
30+
R.string.drawer_item_album to ExpectedLocalizedString(
31+
translations = mapOf(
32+
Locale.ENGLISH to "Albums",
33+
Locale.GERMAN to "Alben"
34+
)
35+
), R.string.create_album to ExpectedLocalizedString(
36+
translations = mapOf(
37+
Locale.ENGLISH to "Create album",
38+
Locale.GERMAN to "Album erstellen"
39+
)
40+
), R.string.create_album_dialog_title to ExpectedLocalizedString(
41+
translations = mapOf(
42+
Locale.ENGLISH to "New album",
43+
Locale.GERMAN to "Neues Album"
44+
)
45+
), R.string.rename_album_dialog_title to ExpectedLocalizedString(
46+
translations = mapOf(
47+
Locale.ENGLISH to "Rename album",
48+
Locale.GERMAN to "Album umbenennen"
49+
)
50+
), R.string.rename_dialog_button to ExpectedLocalizedString(
51+
translations = mapOf(
52+
Locale.ENGLISH to "Rename",
53+
Locale.GERMAN to "Speichern"
54+
)
55+
), R.string.create_album_dialog_message to ExpectedLocalizedString(
56+
translations = mapOf(
57+
Locale.ENGLISH to "Enter your new Album name",
58+
Locale.GERMAN to "Gib einen Namen für das Album ein"
59+
)
60+
), R.string.album_name_empty to ExpectedLocalizedString(
61+
translations = mapOf(
62+
Locale.ENGLISH to "Album name cannot be empty",
63+
Locale.GERMAN to "Der Albumname darf nicht leer sein"
64+
)
65+
), R.string.hidden_album_name to ExpectedLocalizedString(
66+
translations = mapOf(
67+
Locale.ENGLISH to "Album name cannot start with invalid char",
68+
Locale.GERMAN to "Der Albumname darf nicht mit einem ungültigen Zeichen beginnen"
69+
)
70+
), R.string.add_more to ExpectedLocalizedString(
71+
translations = mapOf(
72+
Locale.ENGLISH to "Add more",
73+
Locale.GERMAN to "Mehr hinzufügen"
74+
)
75+
), R.string.album_rename to ExpectedLocalizedString(
76+
translations = mapOf(
77+
Locale.ENGLISH to "Rename Album",
78+
Locale.GERMAN to "Album umbenennen"
79+
)
80+
), R.string.album_delete to ExpectedLocalizedString(
81+
translations = mapOf(
82+
Locale.ENGLISH to "Delete Album",
83+
Locale.GERMAN to "Album löschen"
84+
)
85+
), R.string.album_delete_failed_message to ExpectedLocalizedString(
86+
translations = mapOf(
87+
Locale.ENGLISH to "Failed to delete few of the files.",
88+
Locale.GERMAN to "Einige Dateien konnten nicht gelöscht werden."
89+
)
90+
), R.string.album_already_exists to ExpectedLocalizedString(
91+
translations = mapOf(
92+
Locale.ENGLISH to "Album already exists",
93+
Locale.GERMAN to "Das Album existiert bereits"
94+
)
95+
), R.string.album_picker_toolbar_title to ExpectedLocalizedString(
96+
translations = mapOf(
97+
Locale.ENGLISH to "Pick Album",
98+
Locale.GERMAN to "Album auswählen"
99+
)
100+
), R.string.media_picker_toolbar_title to ExpectedLocalizedString(
101+
translations = mapOf(
102+
Locale.ENGLISH to "Pick Media Files",
103+
Locale.GERMAN to "Mediendateien auswählen"
104+
)
105+
), R.string.empty_albums_title to ExpectedLocalizedString(
106+
translations = mapOf(
107+
Locale.ENGLISH to "Create Albums for your Photos",
108+
Locale.GERMAN to "Erstelle Alben für deine Fotos"
109+
)
110+
), R.string.empty_albums_message to ExpectedLocalizedString(
111+
translations = mapOf(
112+
Locale.ENGLISH to "You can organize all your photos in as many albums as you like. You haven\'t created an album yet.",
113+
Locale.GERMAN to "Sie können all Ihre Fotos in beliebig vielen Alben organisieren. Bisher haben Sie noch kein Album erstellt."
114+
)
115+
), R.string.add_to_album to ExpectedLocalizedString(
116+
translations = mapOf(
117+
Locale.ENGLISH to "Add to Album",
118+
Locale.GERMAN to "Zum Album hinzufügen"
119+
)
120+
), R.string.album_file_added_message to ExpectedLocalizedString(
121+
translations = mapOf(
122+
Locale.ENGLISH to "File added successfully",
123+
Locale.GERMAN to "Datei erfolgreich hinzugefügt"
124+
)
125+
), R.string.empty_album_detailed_view_title to ExpectedLocalizedString(
126+
translations = mapOf(
127+
Locale.ENGLISH to "All that\'s missing are your photos",
128+
Locale.GERMAN to "Es fehlen nur noch Ihre Fotos"
129+
)
130+
), R.string.empty_album_detailed_view_message to ExpectedLocalizedString(
131+
translations = mapOf(
132+
Locale.ENGLISH to "You can add as many photos as you like. A photo can also belong to more than one album.",
133+
Locale.GERMAN to "Sie können so viele Fotos hinzufügen, wie Sie möchten. Ein Foto kann auch mehreren Alben zugeordnet werden."
134+
)
135+
),
136+
R.string.add_photos to ExpectedLocalizedString(
137+
translations = mapOf(
138+
Locale.ENGLISH to "Add photos",
139+
Locale.GERMAN to "Fotos hinzufügen"
140+
)
141+
), R.string.album_items_text to ExpectedLocalizedString(
142+
translations = mapOf(
143+
Locale.ENGLISH to "%d Items — %s",
144+
Locale.GERMAN to "%d Elemente — %s"
145+
)
146+
), R.string.album_unsupported_file to ExpectedLocalizedString(
147+
translations = mapOf(
148+
Locale.ENGLISH to "Unsupported media",
149+
Locale.GERMAN to "Nicht unterstützte Medien"
150+
)
151+
), R.string.album_upload_from_camera_roll to ExpectedLocalizedString(
152+
translations = mapOf(
153+
Locale.ENGLISH to "Upload from cameraroll",
154+
Locale.GERMAN to "Dateien hochladen"
155+
)
156+
), R.string.album_upload_from_account to ExpectedLocalizedString(
157+
translations = mapOf(
158+
Locale.ENGLISH to "Select images from account",
159+
Locale.GERMAN to "Dateien auswählen"
160+
)
161+
), R.string.album_rename_conflict to ExpectedLocalizedString(
162+
translations = mapOf(
163+
Locale.ENGLISH to "This name is already in use.",
164+
Locale.GERMAN to "Dieser Name wird bereits verwendet."
165+
)
166+
), R.string.album_copy_file_conflict to ExpectedLocalizedString(
167+
translations = mapOf(
168+
Locale.ENGLISH to "Already exists.",
169+
Locale.GERMAN to "Existiert bereits."
170+
)
171+
),
172+
)
173+
174+
@Test
175+
fun verifyLocalizedStrings() {
176+
localizedStringMap.forEach { (stringRes, expected) ->
177+
expected.translations.forEach { (locale, expectedText) ->
178+
179+
val config = Configuration(baseContext.resources.configuration)
180+
config.setLocale(locale)
181+
182+
val localizedContext = baseContext.createConfigurationContext(config)
183+
val actualText = localizedContext.getString(stringRes)
184+
185+
assertEquals(
186+
"Mismatch for ${baseContext.resources.getResourceEntryName(stringRes)} in $locale",
187+
expectedText,
188+
actualText
189+
)
190+
}
191+
}
192+
}
193+
194+
data class ExpectedLocalizedString(val translations: Map<Locale, String>)
195+
196+
private val expectedDimenMap = mapOf(
197+
R.dimen.album_list_image_width to ExpectedDimen(
198+
default = 78f,
199+
unit = DimenUnit.DP
200+
),
201+
R.dimen.album_list_image_height to ExpectedDimen(
202+
default = 56f,
203+
unit = DimenUnit.DP
204+
),
205+
R.dimen.album_grid_image_height to ExpectedDimen(
206+
default = 140f,
207+
unit = DimenUnit.DP
208+
),
209+
R.dimen.album_grid_image_corner_radius to ExpectedDimen(
210+
default = 8f,
211+
unit = DimenUnit.DP
212+
),
213+
R.dimen.album_list_image_corner_radius to ExpectedDimen(
214+
default = 4f,
215+
unit = DimenUnit.DP
216+
),
217+
R.dimen.album_grid_spacing to ExpectedDimen(
218+
default = 4f,
219+
unit = DimenUnit.DP
220+
),
221+
R.dimen.album_recycler_view_grid_padding to ExpectedDimen(
222+
default = 8f,
223+
unit = DimenUnit.DP
224+
),
225+
)
226+
227+
@Test
228+
fun validateDefaultDimens() {
229+
validateDimens(
230+
configModifier = { it }, // no change → default values
231+
) { it.default to it.unit }
232+
}
233+
234+
@Test
235+
fun validate_sw600dp_Dimens() {
236+
validateDimens(configModifier = { config ->
237+
config.smallestScreenWidthDp = 600
238+
config
239+
}) { it.alt to it.unit }
240+
}
241+
242+
private fun validateDimens(
243+
configModifier: (Configuration) -> Configuration,
244+
selector: (ExpectedDimen) -> Pair<Float?, DimenUnit>
245+
) {
246+
val baseConfig = Configuration(baseContext.resources.configuration)
247+
val testConfig = configModifier(baseConfig)
248+
val testContext = baseContext.createConfigurationContext(testConfig)
249+
val dm = testContext.resources.displayMetrics
250+
val config = testContext.resources.configuration
251+
expectedDimenMap.forEach { (resId, entry) ->
252+
val (value, unit) = selector(entry)
253+
val actualPx = testContext.resources.getDimension(resId)
254+
value?.let {
255+
val expectedPx = convertToPx(value, unit, dm, config)
256+
assertEquals(
257+
"Mismatch for ${testContext.resources.getResourceEntryName(resId)} ($unit)",
258+
expectedPx,
259+
actualPx,
260+
0.01f
261+
)
262+
}
263+
}
264+
}
265+
266+
private fun convertToPx(
267+
value: Float,
268+
unit: DimenUnit,
269+
dm: DisplayMetrics,
270+
config: Configuration
271+
): Float {
272+
return when (unit) {
273+
DimenUnit.DP -> value * dm.density
274+
DimenUnit.SP -> value * dm.density * config.fontScale
275+
DimenUnit.PX -> value
276+
}
277+
}
278+
279+
data class ExpectedDimen(
280+
val default: Float,
281+
val alt: Float? = null,
282+
val unit: DimenUnit,
283+
)
284+
285+
enum class DimenUnit { DP, SP, PX }
286+
}

app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,9 @@
619619
android:launchMode="singleTop"
620620
android:theme="@style/Theme.ownCloud.Dialog.NoTitle"
621621
android:windowSoftInputMode="adjustResize" />
622+
<activity
623+
android:name=".ui.activity.AlbumsPickerActivity"
624+
android:exported="false" />
622625
<activity
623626
android:name=".ui.activity.ShareActivity"
624627
android:exported="false"

app/src/main/java/com/nextcloud/client/di/ComponentsModule.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import com.nextcloud.ui.ImageDetailFragment;
3333
import com.nextcloud.ui.SetOnlineStatusBottomSheet;
3434
import com.nextcloud.ui.SetStatusMessageBottomSheet;
35+
import com.nextcloud.ui.albumItemActions.AlbumItemActionsBottomSheet;
3536
import com.nextcloud.ui.composeActivity.ComposeActivity;
3637
import com.nextcloud.ui.fileactions.FileActionsBottomSheet;
3738
import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsBottomSheet;
@@ -82,6 +83,7 @@
8283
import com.owncloud.android.ui.dialog.ChooseTemplateDialogFragment;
8384
import com.owncloud.android.ui.dialog.ConfirmationDialogFragment;
8485
import com.owncloud.android.ui.dialog.ConflictsResolveDialog;
86+
import com.owncloud.android.ui.dialog.CreateAlbumDialogFragment;
8587
import com.owncloud.android.ui.dialog.CreateFolderDialogFragment;
8688
import com.owncloud.android.ui.dialog.ExpirationDatePickerDialogFragment;
8789
import com.owncloud.android.ui.dialog.IndeterminateProgressDialog;
@@ -114,6 +116,9 @@
114116
import com.owncloud.android.ui.fragment.OCFileListFragment;
115117
import com.owncloud.android.ui.fragment.SharedListFragment;
116118
import com.owncloud.android.ui.fragment.UnifiedSearchFragment;
119+
import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment;
120+
import com.owncloud.android.ui.fragment.albums.AlbumsFragment;
121+
import com.owncloud.android.ui.activity.AlbumsPickerActivity;
117122
import com.owncloud.android.ui.fragment.contactsbackup.BackupFragment;
118123
import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment;
119124
import com.owncloud.android.ui.preview.FileDownloadFragment;
@@ -505,4 +510,19 @@ abstract class ComponentsModule {
505510

506511
@ContributesAndroidInjector
507512
abstract SetStatusMessageBottomSheet setStatusMessageBottomSheet();
513+
514+
@ContributesAndroidInjector
515+
abstract AlbumsPickerActivity albumsPickerActivity();
516+
517+
@ContributesAndroidInjector
518+
abstract CreateAlbumDialogFragment createAlbumDialogFragment();
519+
520+
@ContributesAndroidInjector
521+
abstract AlbumsFragment albumsFragment();
522+
523+
@ContributesAndroidInjector
524+
abstract AlbumItemsFragment albumItemsFragment();
525+
526+
@ContributesAndroidInjector
527+
abstract AlbumItemActionsBottomSheet albumItemActionsBottomSheet();
508528
}

app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import com.nextcloud.client.jobs.download.FileDownloadWorker
2828
import com.nextcloud.client.jobs.metadata.MetadataWorker
2929
import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker
3030
import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker
31+
import com.nextcloud.client.jobs.upload.AlbumFileUploadWorker
3132
import com.nextcloud.client.jobs.upload.FileUploadWorker
3233
import com.nextcloud.client.logger.Logger
3334
import com.nextcloud.client.network.ConnectivityService
@@ -97,6 +98,7 @@ class BackgroundJobFactory @Inject constructor(
9798
FilesExportWork::class -> createFilesExportWork(context, workerParameters)
9899
FileUploadWorker::class -> createFilesUploadWorker(context, workerParameters)
99100
FileDownloadWorker::class -> createFilesDownloadWorker(context, workerParameters)
101+
AlbumFileUploadWorker::class -> createAlbumsFilesUploadWorker(context, workerParameters)
100102
GeneratePdfFromImagesWork::class -> createPDFGenerateWork(context, workerParameters)
101103
HealthStatusWork::class -> createHealthStatusWork(context, workerParameters)
102104
TestJob::class -> createTestJob(context, workerParameters)
@@ -250,6 +252,20 @@ class BackgroundJobFactory @Inject constructor(
250252
params
251253
)
252254

255+
private fun createAlbumsFilesUploadWorker(context: Context, params: WorkerParameters): AlbumFileUploadWorker =
256+
AlbumFileUploadWorker(
257+
uploadsStorageManager,
258+
connectivityService,
259+
powerManagementService,
260+
accountManager,
261+
viewThemeUtils.get(),
262+
localBroadcastManager.get(),
263+
backgroundJobManager.get(),
264+
preferences,
265+
context,
266+
params
267+
)
268+
253269
private fun createPDFGenerateWork(context: Context, params: WorkerParameters): GeneratePdfFromImagesWork =
254270
GeneratePdfFromImagesWork(
255271
appContext = context,

app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ interface BackgroundJobManager {
138138
fun startNotificationJob(subject: String, signature: String)
139139
fun startAccountRemovalJob(accountName: String, remoteWipe: Boolean)
140140
fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean)
141+
fun startAlbumFilesUploadJob(
142+
user: User,
143+
uploadIds: LongArray,
144+
albumName: String,
145+
showSameFileAlreadyExistsNotification: Boolean
146+
)
141147
fun getFileUploads(user: User): LiveData<List<JobInfo>>
142148
fun cancelFilesUploadJob(user: User)
143149
fun isStartFileUploadJobScheduled(accountName: String): Boolean

0 commit comments

Comments
 (0)