From 1bd3b91e5d7680ed31d1188dbd054afe135e55ee Mon Sep 17 00:00:00 2001 From: elelanv Date: Fri, 5 Dec 2025 19:55:56 +0530 Subject: [PATCH 1/2] for dweb, after creating/joining group, navigate to dweb main screen --- .../settings/SpaceSetupSuccessFragment.kt | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupSuccessFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupSuccessFragment.kt index dbacba29a..411b5d7ab 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupSuccessFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupSuccessFragment.kt @@ -52,25 +52,17 @@ class SpaceSetupSuccessFragment : BaseFragment() { binding.btAuthenticate.setOnClickListener { _ -> if (args.spaceType == Space.Type.RAVEN) { val navController = findNavController() - // Let's clear and navigate to snowbird fragment - val popped = navController.popBackStack(R.id.fragment_snowbird, false) - if (!popped) { - navController.navigate( - R.id.fragment_snowbird, - null, - navOptions { - popUpTo(R.id.fragment_snowbird) { inclusive = true } - launchSingleTop = true - restoreState = true - } - ) - } else { - val intent = Intent(requireActivity(), MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(intent) - } - //val action = SpaceSetupSuccessFragmentDirections.actionFragmentSpaceSetupSuccessToFragmentSnowbird() - //navController.navigate(action) + // Navigate to Snowbird as the new root of this nav graph so Back exits to MainActivity + navController.navigate( + R.id.action_fragment_space_setup_success_to_fragment_snowbird, + null, + navOptions { + // Clear the entire Space Setup nav graph back stack + popUpTo(R.id.space_setup_navigation) { inclusive = true } + launchSingleTop = true + restoreState = false + } + ) } else { val intent = Intent(requireActivity(), MainActivity::class.java) intent.flags = From 8b86123da6475040c70dd6685c907d11c236b2a6 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Fri, 5 Dec 2025 22:04:48 +0530 Subject: [PATCH 2/2] feat: Implement content picker and grid layout for media Replaced the generic file picker with a new content picker sheet that allows users to add media from the camera, gallery, or files. - **Content Picker:** Added a bottom sheet (`ContentPickerFragment`) to choose between Camera, Gallery, and Files. - **Modern Media Pickers:** Implemented modern `ActivityResultContracts` for picking visual media, taking pictures, and opening documents. - **Camera Integration:** Added logic to handle camera permissions and launch either the native camera or a custom camera activity. - **Grid Layout:** Changed the file list from a `LinearLayoutManager` to a `GridLayoutManager` for a visual, grid-based display. - **File Type Filtering:** The file picker now filters for supported media types (image, video, audio) and shows a warning for invalid files. - **Open Downloaded Files:** Implemented functionality to open downloaded files using an `ACTION_VIEW` intent. - **UI Enhancements:** - A new `snowbird_media_grid_item.xml` layout was created for grid items. - The adapter now displays appropriate icons based on file type (image, video, audio). --- .../snowbird/SnowbirdFileListAdapter.kt | 67 ++-- .../snowbird/SnowbirdFileListFragment.kt | 340 +++++++++++++++++- .../res/layout/snowbird_media_grid_item.xml | 70 ++++ 3 files changed, 438 insertions(+), 39 deletions(-) create mode 100644 app/src/main/res/layout/snowbird_media_grid_item.xml diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListAdapter.kt index 3d7f123b7..488646c3a 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListAdapter.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListAdapter.kt @@ -1,20 +1,22 @@ package net.opendasharchive.openarchive.services.snowbird +import android.content.res.ColorStateList import android.os.Build import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import android.widget.ImageView import androidx.annotation.RequiresExtension import androidx.core.content.ContextCompat import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.databinding.OneLineRowBinding +import net.opendasharchive.openarchive.databinding.SnowbirdMediaGridItemBinding import net.opendasharchive.openarchive.db.SnowbirdFileItem -import net.opendasharchive.openarchive.extensions.scaled import java.lang.ref.WeakReference -class SnowbirdFileViewHolder(val binding: OneLineRowBinding) : RecyclerView.ViewHolder(binding.root) +class SnowbirdFileViewHolder(val binding: SnowbirdMediaGridItemBinding) : RecyclerView.ViewHolder(binding.root) class SnowbirdFileListAdapter( onClickListener: ((SnowbirdFileItem) -> Unit)? = null, @@ -25,7 +27,7 @@ class SnowbirdFileListAdapter( private val onLongPressCallback = WeakReference(onLongPressListener) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SnowbirdFileViewHolder { - val binding = OneLineRowBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val binding = SnowbirdMediaGridItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) return SnowbirdFileViewHolder(binding) } @@ -34,39 +36,54 @@ class SnowbirdFileListAdapter( val item = getItem(position) with (holder.binding) { - val context = button.context + val context = root.context - button.setLeftIcon(ContextCompat.getDrawable(context, R.drawable.ic_dweb)?.scaled(40, context)) - //button.setBackgroundResource(R.drawable.button_outlined_ripple) - button.setTitle(item.name ?: "No name provided") + // Set filename + name.text = item.name ?: "No name provided" - if (item.isDownloaded) { - button.setRightIcon(ContextCompat.getDrawable(context, R.drawable.outline_cloud_done_24)?.scaled(40, context)) - } else { - button.setRightIcon(ContextCompat.getDrawable(context, R.drawable.outline_cloud_download_24)?.scaled(40, context)) + // Determine file type and show appropriate icon + val fileExtension = item.name?.substringAfterLast(".", "")?.lowercase() ?: "" + + when { + isImageFile(fileExtension) -> setDefaultIcon(R.drawable.ic_image) + isVideoFile(fileExtension) -> setDefaultIcon(R.drawable.ic_video) + isAudioFile(fileExtension) -> setDefaultIcon(R.drawable.ic_music) + else -> setDefaultIcon(R.drawable.ic_folder_new) } - button.setOnClickListener { + // Show download badge if not downloaded + downloadBadge.visibility = if (item.isDownloaded) View.GONE else View.VISIBLE + + root.setOnClickListener { onClickCallback.get()?.invoke(item) } - button.setOnLongClickListener { + root.setOnLongClickListener { onLongPressCallback.get()?.invoke(item) true } - - if (item.size > 0) { - // convert bytes to human-readable format - val sizeText = when { - item.size >= 1_000_000_000 -> "${item.size / 1_000_000_000.0} GB" - item.size >= 1_000_000 -> "${item.size / 1_000_000.0} MB" - item.size >= 1_000 -> "${item.size / 1_000.0} KB" - else -> "${item.size} bytes" - } - button.setSubTitle(sizeText) - } } } + + private fun SnowbirdMediaGridItemBinding.setDefaultIcon(iconRes: Int) { + icon.scaleType = ImageView.ScaleType.CENTER_INSIDE + icon.setImageDrawable(ContextCompat.getDrawable(root.context, iconRes)?.mutate()) + icon.imageTintList = ColorStateList.valueOf( + ContextCompat.getColor(root.context, R.color.colorOnBackground) + ) + } + + private fun isImageFile(extension: String): Boolean { + return extension in listOf("jpg", "jpeg", "png", "gif", "bmp", "webp", "heic", "heif") + } + + private fun isVideoFile(extension: String): Boolean { + return extension in listOf("mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "3gp") + } + + private fun isAudioFile(extension: String): Boolean { + return extension in listOf("mp3", "wav", "ogg", "m4a", "flac", "aac", "wma") + } } class SnowbirdFileDiffCallback : DiffUtil.ItemCallback() { diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt index 313db11a4..300c5a369 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt @@ -1,5 +1,8 @@ package net.opendasharchive.openarchive.services.snowbird +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle import android.view.LayoutInflater @@ -8,12 +11,16 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.webkit.MimeTypeMap +import android.widget.Toast +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.GridLayoutManager import kotlinx.coroutines.launch import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.databinding.FragmentSnowbirdListMediaBinding @@ -21,20 +28,29 @@ import net.opendasharchive.openarchive.db.FileUploadResult import net.opendasharchive.openarchive.db.SnowbirdError import net.opendasharchive.openarchive.db.SnowbirdFileItem import net.opendasharchive.openarchive.extensions.androidViewModel -import net.opendasharchive.openarchive.features.core.BaseFragment import net.opendasharchive.openarchive.features.core.UiText import net.opendasharchive.openarchive.features.core.dialog.DialogType import net.opendasharchive.openarchive.features.core.dialog.showDialog +import net.opendasharchive.openarchive.features.media.AddMediaType +import net.opendasharchive.openarchive.features.media.ContentPickerFragment +import net.opendasharchive.openarchive.features.media.Picker +import net.opendasharchive.openarchive.features.media.camera.CameraActivity +import net.opendasharchive.openarchive.features.media.camera.CameraConfig +import net.opendasharchive.openarchive.features.settings.passcode.AppConfig import net.opendasharchive.openarchive.util.SpacingItemDecoration +import org.koin.android.ext.android.inject import timber.log.Timber +import java.io.File class SnowbirdFileListFragment : BaseSnowbirdFragment() { private val snowbirdFileViewModel: SnowbirdFileViewModel by androidViewModel() + private val appConfig: AppConfig by inject() private lateinit var viewBinding: FragmentSnowbirdListMediaBinding private lateinit var adapter: SnowbirdFileListAdapter private lateinit var groupKey: String private lateinit var repoKey: String + private var currentPhotoUri: Uri? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -51,6 +67,91 @@ class SnowbirdFileListFragment : BaseSnowbirdFragment() { return viewBinding.root } + // Permission launcher for camera + private val cameraPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + launchCamera() + } + } + + // Modern visual media picker for gallery (supports up to 10 items) + private val galleryLauncher = + registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(10)) { uris: List? -> + if (!uris.isNullOrEmpty()) { + handleSelectedFiles(uris) + } else { + Timber.d("No media selected from gallery") + } + } + + // Document picker for file browser (shows actual file manager, not gallery) + private val filePickerLauncher = + registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris: List -> + if (!uris.isNullOrEmpty()) { + // Filter to only allow media files + val filteredUris = uris.filter { uri -> + val mimeType = requireContext().contentResolver.getType(uri) + mimeType?.startsWith("image/") == true || + mimeType?.startsWith("video/") == true || + mimeType?.startsWith("audio/") == true + } + + if (filteredUris.isEmpty()) { + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Warning + title = UiText.DynamicString("Invalid File Type") + message = UiText.DynamicString("Please select only image, video, or audio files. Other file types are not supported.") + positiveButton { + text = UiText.StringResource(R.string.lbl_ok) + } + } + } else { + if (filteredUris.size < uris.size) { + // Some files were filtered out + Toast.makeText( + requireContext(), + "Some files were skipped (only images, videos, and audio are supported)", + Toast.LENGTH_LONG + ).show() + } + handleSelectedFiles(filteredUris) + } + } + } + + // Modern camera launcher using TakePicture contract for photo capture + private val modernCameraLauncher = + registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> + if (success && currentPhotoUri != null) { + currentPhotoUri?.let { uri -> + Timber.d("Processing camera capture from URI: $uri") + handleMedia(uri) + } + currentPhotoUri = null + } else { + Timber.d("Camera capture cancelled or failed") + currentPhotoUri = null + } + } + + // Custom camera launcher for video and photo with multiple capture + private val customCameraLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + val capturedUris = + result.data?.getStringArrayListExtra(CameraActivity.EXTRA_CAPTURED_URIS) + if (!capturedUris.isNullOrEmpty()) { + val uris = capturedUris.map { Uri.parse(it) } + handleSelectedFiles(uris) + } else { + Timber.w("No captures returned from custom camera") + } + } else { + Timber.w("Custom camera capture cancelled or failed") + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -69,8 +170,8 @@ class SnowbirdFileListFragment : BaseSnowbirdFragment() { override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_add -> { - Timber.d("Adde!") - openFilePicker() + Timber.d("Add button clicked!") + openContentPickerSheet() true } else -> false @@ -79,10 +180,6 @@ class SnowbirdFileListFragment : BaseSnowbirdFragment() { }, viewLifecycleOwner, Lifecycle.State.RESUMED) } - private val getMultipleContentsLauncher = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris: List -> - handleSelectedFiles(uris) - } - private fun handleAudio(uri: Uri) { handleMedia(uri) } @@ -102,6 +199,7 @@ class SnowbirdFileListFragment : BaseSnowbirdFragment() { private fun handleSelectedFiles(uris: List) { if (uris.isNotEmpty()) { + var unsupportedCount = 0 for (uri in uris) { val mimeType = requireContext().contentResolver.getType(uri) when { @@ -109,17 +207,131 @@ class SnowbirdFileListFragment : BaseSnowbirdFragment() { mimeType?.startsWith("video/") == true -> handleVideo(uri) mimeType?.startsWith("audio/") == true -> handleAudio(uri) else -> { - Timber.d("Unknown type picked: $mimeType") + unsupportedCount++ + Timber.w("Unsupported file type: $mimeType for URI: $uri") } } } + + if (unsupportedCount > 0) { + Toast.makeText( + requireContext(), + "$unsupportedCount file(s) skipped. Only images, videos, and audio are supported.", + Toast.LENGTH_LONG + ).show() + } } else { - Timber.d("No images selected") + Timber.d("No files selected") } } private fun openFilePicker() { - getMultipleContentsLauncher.launch("*/*") + // Use OpenMultipleDocuments to show the file browser (not gallery) + // Only allow media file types (images, videos, audio) + try { + filePickerLauncher.launch(arrayOf("image/*", "video/*", "audio/*")) + } catch (e: Exception) { + Timber.e(e, "Error launching file picker") + Toast.makeText( + requireContext(), + "Could not open file picker", + Toast.LENGTH_SHORT + ).show() + } + } + + private fun openContentPickerSheet() { + val contentPickerSheet = ContentPickerFragment { mediaType -> + handleMediaTypeSelection(mediaType) + } + contentPickerSheet.show(parentFragmentManager, ContentPickerFragment.TAG) + } + + private fun handleMediaTypeSelection(mediaType: AddMediaType) { + when (mediaType) { + AddMediaType.CAMERA -> openCamera() + AddMediaType.GALLERY -> openGallery() + AddMediaType.FILES -> openFilePicker() + } + } + + private fun openCamera() { + when { + ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED -> { + launchCamera() + } + + shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> { + // Show rationale dialog + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Warning + title = UiText.DynamicString("Camera Permission") + message = UiText.DynamicString("Camera access is needed to take pictures. Please grant permission.") + positiveButton { + text = UiText.DynamicString("Accept") + action = { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + neutralButton { + text = UiText.DynamicString("Cancel") + } + } + } + + else -> { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + } + + private fun launchCamera() { + if (appConfig.useCustomCamera) { + // Use custom camera with photo and video support + val cameraConfig = CameraConfig( + allowVideoCapture = true, + allowPhotoCapture = true, + allowMultipleCapture = false, + enablePreview = true, + showFlashToggle = true, + showGridToggle = true, + showCameraSwitch = true + ) + Picker.launchCustomCamera( + requireActivity(), + customCameraLauncher, + cameraConfig + ) + } else { + // Use system camera + val photoFile = File(requireContext().cacheDir, "camera_${System.currentTimeMillis()}.jpg") + currentPhotoUri = androidx.core.content.FileProvider.getUriForFile( + requireContext(), + "${requireContext().packageName}.fileprovider", + photoFile + ) + modernCameraLauncher.launch(currentPhotoUri) + } + } + + private fun openGallery() { + // PickVisualMedia doesn't require READ_MEDIA permissions on Android 13+ + // The system photo picker handles access internally + launchGallery() + } + + private fun launchGallery() { + val request = PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + try { + galleryLauncher.launch(request) + } catch (e: Exception) { + Timber.e(e, "Error launching gallery picker") + // Fallback to file picker if gallery fails + openFilePicker() + } } private fun setupRecyclerView() { @@ -131,8 +343,7 @@ class SnowbirdFileListFragment : BaseSnowbirdFragment() { viewBinding.snowbirdMediaRecyclerView.addItemDecoration(SpacingItemDecoration(spacingInPixels)) viewBinding.snowbirdMediaRecyclerView.setEmptyView(R.layout.view_empty_state) - // viewBinding.snowbirdMediaRecyclerView.layoutManager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL) - viewBinding.snowbirdMediaRecyclerView.layoutManager = LinearLayoutManager(requireContext()) + viewBinding.snowbirdMediaRecyclerView.layoutManager = GridLayoutManager(requireContext(), 3) viewBinding.snowbirdMediaRecyclerView.adapter = adapter } @@ -194,13 +405,114 @@ class SnowbirdFileListFragment : BaseSnowbirdFragment() { private fun onFileDownloaded(uri: Uri) { handleLoadingStatus(false) Timber.d("File successfully downloaded: $uri") + dialogManager.showDialog(dialogManager.requireResourceProvider()) { type = DialogType.Success title = UiText.StringResource(R.string.label_success_title) message = UiText.DynamicString("File successfully downloaded") positiveButton { - text = UiText.StringResource(R.string.label_got_it) + text = UiText.DynamicString("Open") + action = { + openDownloadedFile(uri) + } } + neutralButton { + text = UiText.StringResource(R.string.lbl_ok) + } + } + } + + private fun openDownloadedFile(uri: Uri) { + try { + // The URI is already a FileProvider content URI, we can use it directly + // Extract filename from the URI to determine MIME type + val filename = uri.lastPathSegment ?: "file" + val mimeType = getMimeType(filename) ?: "*/*" + + Timber.d("Opening file with URI: $uri, MIME type: $mimeType") + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + // Check if there's an app that can handle this intent + if (intent.resolveActivity(requireContext().packageManager) != null) { + startActivity(intent) + } else { + // Fallback: try to open with file manager using generic MIME type + val fileManagerIntent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "*/*") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + val chooser = Intent.createChooser(fileManagerIntent, "Open file with") + if (chooser.resolveActivity(requireContext().packageManager) != null) { + startActivity(chooser) + } else { + // No app can handle this + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Warning + title = UiText.DynamicString("No App Found") + message = UiText.DynamicString("No app is available to open this type of file.") + positiveButton { + text = UiText.StringResource(R.string.lbl_ok) + } + } + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to open downloaded file") + dialogManager.showDialog(dialogManager.requireResourceProvider()) { + type = DialogType.Error + title = UiText.DynamicString("Error") + message = UiText.DynamicString("Could not open file: ${e.message}") + positiveButton { + text = UiText.StringResource(R.string.lbl_ok) + } + } + } + } + + private fun getMimeType(fileName: String): String? { + val extension = fileName.substringAfterLast(".", "") + return when (extension.lowercase()) { + // Images + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "bmp" -> "image/bmp" + "webp" -> "image/webp" + "heic", "heif" -> "image/heic" + + // Videos + "mp4" -> "video/mp4" + "avi" -> "video/x-msvideo" + "mkv" -> "video/x-matroska" + "mov" -> "video/quicktime" + "wmv" -> "video/x-ms-wmv" + "flv" -> "video/x-flv" + "webm" -> "video/webm" + "3gp" -> "video/3gpp" + + // Audio + "mp3" -> "audio/mpeg" + "wav" -> "audio/wav" + "ogg" -> "audio/ogg" + "m4a" -> "audio/mp4" + "flac" -> "audio/flac" + "aac" -> "audio/aac" + "wma" -> "audio/x-ms-wma" + + // Documents + "pdf" -> "application/pdf" + "doc" -> "application/msword" + "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + "txt" -> "text/plain" + + else -> MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) } } diff --git a/app/src/main/res/layout/snowbird_media_grid_item.xml b/app/src/main/res/layout/snowbird_media_grid_item.xml new file mode 100644 index 000000000..da03cb9b2 --- /dev/null +++ b/app/src/main/res/layout/snowbird_media_grid_item.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + +