diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 02d10de9e..218f7a83d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -60,8 +60,8 @@ android {
applicationId = "net.opendasharchive.openarchive"
minSdk = 29
targetSdk = 36
- versionCode = 30021
- versionName = "4.0.3"
+ versionCode = 30027
+ versionName = "4.0.4"
multiDexEnabled = true
vectorDrawables.useSupportLibrary = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -130,9 +130,13 @@ android {
resources {
excludes.addAll(
listOf(
- "META-INF/LICENSE.txt", "META-INF/NOTICE.txt", "META-INF/LICENSE",
- "META-INF/NOTICE", "META-INF/DEPENDENCIES", "LICENSE.txt"
- )
+ "META-INF/LICENSE.txt",
+ "META-INF/NOTICE.txt",
+ "META-INF/LICENSE",
+ "META-INF/NOTICE",
+ "META-INF/DEPENDENCIES",
+ "LICENSE.txt",
+ ),
)
}
}
@@ -241,12 +245,14 @@ dependencies {
implementation(libs.retrofit.gson)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.guardianproject.sardine)
+ implementation(libs.jsoup)
// Images & Media
implementation(libs.coil)
implementation(libs.coil.compose)
implementation(libs.coil.video)
implementation(libs.coil.network)
+ implementation(libs.picasso)
// CameraX
implementation(libs.androidx.camera.core)
@@ -256,6 +262,10 @@ dependencies {
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.extensions)
+ // Barcode Scanning
+ implementation(libs.zxing.core)
+ implementation(libs.zxing.android.embedded)
+
// Media3 - ExoPlayer
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 558268068..1120db749 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -55,6 +55,7 @@
-->
+
+
diff --git a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt
index 79564f946..9bb643b58 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt
@@ -2,20 +2,18 @@ package net.opendasharchive.openarchive
import android.app.NotificationChannel
import android.app.NotificationManager
-import android.app.UiModeManager
import android.content.Context
-import android.os.Build
import androidx.appcompat.app.AppCompatDelegate
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
+import androidx.lifecycle.lifecycleScope
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.video.VideoFrameDecoder
import com.orm.SugarApp
import info.guardianproject.netcipher.proxy.OrbotHelper
-import androidx.lifecycle.DefaultLifecycleObserver
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.ProcessLifecycleOwner
-import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import net.opendasharchive.openarchive.analytics.api.AnalyticsManager
import net.opendasharchive.openarchive.analytics.api.session.SessionTracker
@@ -27,10 +25,11 @@ import net.opendasharchive.openarchive.core.di.retrofitModule
import net.opendasharchive.openarchive.core.di.unixSocketModule
import net.opendasharchive.openarchive.core.logger.AppLogger
import net.opendasharchive.openarchive.features.settings.passcode.PasscodeManager
+import net.opendasharchive.openarchive.services.storacha.di.storachaModule
import net.opendasharchive.openarchive.util.Prefs
+import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
-import org.koin.android.ext.android.inject
import org.koin.core.context.startKoin
import org.koin.core.logger.Level
@@ -71,6 +70,8 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv
retrofitModule,
unixSocketModule,
passcodeModule,
+ storachaModule,
+ passcodeModule,
analyticsModule(
mixpanelToken = getString(R.string.mixpanel_key),
cleanInsightsConsentChecker = { CleanInsightsManager.hasConsent() }
diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt b/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt
index 8be81f971..d9067fea5 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/db/Space.kt
@@ -43,7 +43,6 @@ data class Space(
private var licenseUrl: String? = null,
// private var chunking: Boolean? = null
) : SugarRecord() {
-
constructor(type: Type) : this() {
tType = type
@@ -55,25 +54,33 @@ data class Space(
}
Type.RAVEN -> "Raven"
+ Type.STORACHA -> "Storacha Service"
}
}
- enum class Type(val id: Int, val friendlyName: String) {
+ enum class Type(
+ val id: Int,
+ val friendlyName: String,
+ ) {
WEBDAV(0, "Private Server"),
INTERNET_ARCHIVE(1, IaConduit.NAME),
RAVEN(5, "DWeb Storage"),
+ STORACHA(7, "Storacha Service"),
}
enum class IconStyle {
- SOLID, OUTLINE
+ SOLID,
+ OUTLINE,
}
companion object {
- fun getAll(): Iterator {
- return findAll(Space::class.java)
- }
+ fun getAll(): Iterator = findAll(Space::class.java)
- fun get(type: Type, host: String? = null, username: String? = null): List {
+ fun get(
+ type: Type,
+ host: String? = null,
+ username: String? = null,
+ ): List {
var whereClause = "type = ?"
val whereArgs = mutableListOf(type.id.toString())
@@ -88,14 +95,20 @@ data class Space(
}
return find(
- Space::class.java, whereClause, whereArgs.toTypedArray(),
- null, null, null
+ Space::class.java,
+ whereClause,
+ whereArgs.toTypedArray(),
+ null,
+ null,
+ null,
)
}
- fun has(type: Type, host: String? = null, username: String? = null): Boolean {
- return get(type, host, username).isNotEmpty()
- }
+ fun has(
+ type: Type,
+ host: String? = null,
+ username: String? = null,
+ ): Boolean = get(type, host, username).isNotEmpty()
var current: Space?
get() {
@@ -107,9 +120,7 @@ data class Space(
Prefs.currentSpaceId = value?.id ?: -1
}
- fun get(id: Long): Space? {
- return findById(Space::class.java, id)
- }
+ fun get(id: Long): Space? = findById(Space::class.java, id)
fun navigate(activity: AppCompatActivity) {
if (getAll().hasNext()) {
@@ -161,24 +172,26 @@ data class Space(
// }
val projects: List
- get() = find(
- Project::class.java,
- "space_id = ? AND NOT archived",
- arrayOf(id.toString()),
- null,
- "id DESC",
- null
- )
+ get() =
+ find(
+ Project::class.java,
+ "space_id = ? AND NOT archived",
+ arrayOf(id.toString()),
+ null,
+ "id DESC",
+ null,
+ )
val archivedProjects: List
- get() = find(
- Project::class.java,
- "space_id = ? AND archived",
- arrayOf(id.toString()),
- null,
- "id DESC",
- null
- )
+ get() =
+ find(
+ Project::class.java,
+ "space_id = ? AND archived",
+ arrayOf(id.toString()),
+ null,
+ "id DESC",
+ null,
+ )
fun hasProject(description: String): Boolean {
// Cannot use `count` from Kotlin due to strange in method signature.
@@ -186,34 +199,40 @@ data class Space(
Project::class.java,
"space_id = ? AND description = ?",
id.toString(),
- description
+ description,
).isNotEmpty()
}
- fun getAvatar(context: Context): Drawable? {
-
-
- return when (tType) {
+ fun getAvatar(context: Context): Drawable? =
+ when (tType) {
Type.WEBDAV -> ContextCompat.getDrawable(context, R.drawable.ic_private_server)
- Type.INTERNET_ARCHIVE -> ContextCompat.getDrawable(context, R.drawable.ic_internet_archive)
+ Type.INTERNET_ARCHIVE ->
+ ContextCompat.getDrawable(
+ context,
+ R.drawable.ic_internet_archive,
+ )
Type.RAVEN -> ContextCompat.getDrawable(context, R.drawable.ic_dweb)
+ Type.STORACHA ->
+ ContextCompat.getDrawable(
+ context,
+ R.drawable.storacha,
+ )
}
- }
@Composable
- fun getAvatar(): Painter {
-
- return when (tType) {
+ fun getAvatar(): Painter =
+ when (tType) {
Type.WEBDAV -> painterResource(R.drawable.ic_space_private_server)
Type.INTERNET_ARCHIVE -> painterResource(R.drawable.ic_space_interent_archive)
Type.RAVEN -> painterResource(R.drawable.ic_space_dweb)
+
+ Type.STORACHA -> painterResource(R.drawable.storacha)
}
- }
fun setAvatar(view: ImageView) {
when (tType) {
@@ -223,7 +242,6 @@ data class Space(
else -> {
view.setImageDrawable(getAvatar(view.context))
-
}
}
}
@@ -235,4 +253,4 @@ data class Space(
return super.delete()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt
index 3e95230d9..4d69a9296 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt
@@ -4,15 +4,19 @@ import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.launch
+import net.opendasharchive.openarchive.R
import net.opendasharchive.openarchive.analytics.api.AnalyticsManager
import net.opendasharchive.openarchive.analytics.api.session.SessionTracker
import net.opendasharchive.openarchive.core.logger.AppLogger
import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager
+import net.opendasharchive.openarchive.features.core.dialog.DialogType
+import net.opendasharchive.openarchive.features.core.dialog.showDialog
import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity
+import net.opendasharchive.openarchive.services.storacha.util.SessionManager
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.activityViewModel
-import kotlin.getValue
abstract class BaseFragment : Fragment(), ToolbarConfigurable {
@@ -79,4 +83,94 @@ abstract class BaseFragment : Fragment(), ToolbarConfigurable {
// Store as previous screen for navigation tracking
previousScreen = screenName
}
+
+ /**
+ * Shows a dialog informing the user that their session has expired.
+ * Common method for all fragments to handle session expiration.
+ * Automatically removes the invalid account and navigates to login.
+ */
+ protected fun showSessionExpiredDialog() {
+ dialogManager.showDialog(dialogManager.requireResourceProvider()) {
+ type = DialogType.Warning
+ title = UiText.StringResource(R.string.session_expired_title)
+ message = UiText.StringResource(R.string.session_expired_message)
+ positiveButton {
+ text = UiText.StringResource(R.string.lbl_ok)
+ action = {
+ try {
+ // Remove invalid account
+ val sessionManager: SessionManager by inject()
+ sessionManager.removeCurrentAccount()
+
+ // Navigate to login with cleared back stack
+ findNavController().navigate(
+ R.id.fragment_storacha_login,
+ null,
+ androidx.navigation.navOptions {
+ popUpTo(R.id.fragment_storacha) {
+ inclusive = false
+ }
+ }
+ )
+ } catch (e: Exception) {
+ // Navigation might fail if not in Storacha nav graph
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Shows a dialog informing the user that their session has expired,
+ * with an option to stay on the current screen (for screens that work without auth,
+ * like browsing delegated spaces).
+ *
+ * @param onStayHere Callback when "Stay Here" is clicked (clear flag and refresh without session)
+ */
+ protected fun showSessionExpiredWithStayOption(onStayHere: () -> Unit = {}) {
+ dialogManager.showDialog(dialogManager.requireResourceProvider()) {
+ type = DialogType.Warning
+ title = UiText.StringResource(R.string.session_expired_title)
+ message = UiText.StringResource(R.string.session_expired_can_continue_message)
+ positiveButton {
+ text = UiText.StringResource(R.string.login)
+ action = {
+ try {
+ // Remove invalid account
+ val sessionManager: SessionManager by inject()
+ sessionManager.removeCurrentAccount()
+
+ // Navigate to login with cleared back stack
+ findNavController().navigate(
+ R.id.fragment_storacha_login,
+ null,
+ androidx.navigation.navOptions {
+ popUpTo(R.id.fragment_storacha) {
+ inclusive = false
+ }
+ }
+ )
+ } catch (e: Exception) {
+ // Navigation might fail if not in Storacha nav graph
+ }
+ }
+ }
+ neutralButton {
+ text = UiText.StringResource(R.string.stay_here)
+ action = {
+ // Remove invalid account but don't navigate to login
+ // User can continue browsing delegated spaces without authentication
+ try {
+ val sessionManager: SessionManager by inject()
+ sessionManager.removeCurrentAccount()
+ } catch (e: Exception) {
+ // Continue even if removal fails
+ }
+
+ // Call callback to clear flag and refresh
+ onStayHere()
+ }
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt
index 626f56eb8..b347daaae 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt
@@ -72,6 +72,7 @@ import net.opendasharchive.openarchive.features.settings.passcode.AppConfig
import net.opendasharchive.openarchive.services.snowbird.SnowbirdActivity
import net.opendasharchive.openarchive.services.snowbird.SnowbirdBridge
import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService
+import net.opendasharchive.openarchive.services.storacha.util.StorachaHelper
import net.opendasharchive.openarchive.upload.UploadManagerFragment
import net.opendasharchive.openarchive.upload.UploadService
import net.opendasharchive.openarchive.util.InAppReviewHelper
@@ -88,9 +89,10 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import java.text.NumberFormat
-
-class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAdapterListener {
-
+class MainActivity :
+ BaseActivity(),
+ SpaceDrawerAdapterListener,
+ FolderDrawerAdapterListener {
private val appConfig by inject()
private val viewModel by viewModel()
@@ -206,7 +208,6 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
// decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
// }
-
binding = ActivityMainBinding.inflate(layoutInflater)
// binding.contentMain.imgLogo.applyEdgeToEdgeInsets { insets ->
@@ -226,7 +227,6 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
bottomMargin = insets.bottom
}
-
setContentView(binding.root)
// Initialize the permission manager with this activity and its dialogManager.
@@ -258,7 +258,6 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
handleIntent(intent)
}
-
if (BuildConfig.DEBUG) {
binding.contentMain.imgLogo.setOnLongClickListener {
startActivity(Intent(this, HomeActivity::class.java))
@@ -275,7 +274,7 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
supportFragmentManager.setFragmentResultListener(
ContentPickerFragment.KEY_DISMISS,
- this
+ this,
) { _, _ ->
// when the sheet goes away, show your arrow
getCurrentMediaFragment()?.setArrowVisible(true)
@@ -408,15 +407,16 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
// ----- Initialization Methods -----
private fun initMediaLaunchers() {
- mediaLaunchers = Picker.register(
- activity = this,
- root = binding.root,
- project = { getSelectedProject() },
- completed = { media ->
- refreshCurrentProject()
- if (media.isNotEmpty()) navigateToPreview()
- }
- )
+ mediaLaunchers =
+ Picker.register(
+ activity = this,
+ root = binding.root,
+ project = { getSelectedProject() },
+ completed = { media ->
+ refreshCurrentProject()
+ if (media.isNotEmpty()) navigateToPreview()
+ },
+ )
}
private fun setupToolbarAndPager() {
@@ -563,13 +563,16 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
}
}
supportFragmentManager.setFragmentResultListener(
- AddMediaDialogFragment.RESP_TAKE_PHOTO, this@MainActivity
+ AddMediaDialogFragment.RESP_TAKE_PHOTO,
+ this@MainActivity,
) { _, _ -> addClicked(AddMediaType.CAMERA) }
supportFragmentManager.setFragmentResultListener(
- AddMediaDialogFragment.RESP_PHOTO_GALLERY, this@MainActivity
+ AddMediaDialogFragment.RESP_PHOTO_GALLERY,
+ this@MainActivity,
) { _, _ -> addClicked(AddMediaType.GALLERY) }
supportFragmentManager.setFragmentResultListener(
- AddMediaDialogFragment.RESP_FILES, this@MainActivity
+ AddMediaDialogFragment.RESP_FILES,
+ this@MainActivity,
) { _, _ -> addClicked(AddMediaType.FILES) }
}
}
@@ -596,20 +599,26 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
// Listen for the "done" action to commit a rename.
binding.contentMain.etFolderName.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
- val newName = binding.contentMain.etFolderName.text.toString().trim()
+ val newName =
+ binding.contentMain.etFolderName.text
+ .toString()
+ .trim()
if (newName.isNotEmpty()) {
renameCurrentFolder(newName)
hideKeyboard()
setFolderBarMode(FolderBarMode.INFO)
} else {
- Snackbar.make(
- binding.root,
- getString(R.string.folder_empty_warning),
- Snackbar.LENGTH_SHORT
- ).show()
+ Snackbar
+ .make(
+ binding.root,
+ getString(R.string.folder_empty_warning),
+ Snackbar.LENGTH_SHORT,
+ ).show()
}
true
- } else false
+ } else {
+ false
+ }
}
binding.contentMain.btnRemoveSelected.setOnClickListener {
@@ -624,25 +633,27 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
it.description = newName
it.save()
refreshCurrentProject()
- Snackbar.make(
- binding.root,
- getString(R.string.folder_rename_success),
- Snackbar.LENGTH_SHORT
- ).show()
+ Snackbar
+ .make(
+ binding.root,
+ getString(R.string.folder_rename_success),
+ Snackbar.LENGTH_SHORT,
+ ).show()
}
}
private fun showFolderOptionsPopup(p: Point) {
val layoutInflater = getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater
val popupBinding = PopupFolderOptionsBinding.inflate(layoutInflater)
- val popup = PopupWindow(this).apply {
- contentView = popupBinding.root
- width = LinearLayout.LayoutParams.WRAP_CONTENT
- height = LinearLayout.LayoutParams.WRAP_CONTENT
- isFocusable = true
- setBackgroundDrawable(ColorDrawable())
- animationStyle = R.style.popup_window_animation
- }
+ val popup =
+ PopupWindow(this).apply {
+ contentView = popupBinding.root
+ width = LinearLayout.LayoutParams.WRAP_CONTENT
+ height = LinearLayout.LayoutParams.WRAP_CONTENT
+ isFocusable = true
+ setBackgroundDrawable(ColorDrawable())
+ animationStyle = R.style.popup_window_animation
+ }
// Check if there is at least one media item in the selected project
val hasMedia = getSelectedProject()?.collections?.any { it.media.isNotEmpty() } == true
@@ -674,17 +685,19 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
refreshProjects()
updateCurrentFolderVisibility()
refreshCurrentProject()
- Snackbar.make(
- binding.root,
- getString(R.string.folder_archived),
- Snackbar.LENGTH_SHORT
- ).show()
+ Snackbar
+ .make(
+ binding.root,
+ getString(R.string.folder_archived),
+ Snackbar.LENGTH_SHORT,
+ ).show()
} else {
- Snackbar.make(
- binding.root,
- getString(R.string.folder_not_found),
- Snackbar.LENGTH_LONG
- ).show()
+ Snackbar
+ .make(
+ binding.root,
+ getString(R.string.folder_not_found),
+ Snackbar.LENGTH_LONG,
+ ).show()
}
}
@@ -694,11 +707,12 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
if (getSelectedProject() != null) {
showDeleteFolderConfirmDialog()
} else {
- Snackbar.make(
- binding.root,
- getString(R.string.folder_not_found),
- Snackbar.LENGTH_LONG
- ).show()
+ Snackbar
+ .make(
+ binding.root,
+ getString(R.string.folder_not_found),
+ Snackbar.LENGTH_LONG,
+ ).show()
}
}
@@ -768,11 +782,12 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
refreshProjects()
updateCurrentFolderVisibility()
refreshCurrentProject()
- Snackbar.make(
- binding.root,
- getString(R.string.folder_removed),
- Snackbar.LENGTH_SHORT
- ).show()
+ Snackbar
+ .make(
+ binding.root,
+ getString(R.string.folder_removed),
+ Snackbar.LENGTH_SHORT,
+ ).show()
}
}
neutralButton {
@@ -789,7 +804,6 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
return supportFragmentManager.findFragmentByTag("f$currentItem") as? MainMediaFragment
}
-
// ----- Drawer Helpers -----
private fun toggleDrawerState() {
if (binding.drawerLayout.isDrawerOpen(binding.drawerContent)) {
@@ -821,7 +835,7 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
private fun expandSpacesList() {
serverListCurOffset = 0f
binding.spaceListMore.setImageDrawable(
- ContextCompat.getDrawable(this, R.drawable.ic_expand_less)
+ ContextCompat.getDrawable(this, R.drawable.ic_expand_less),
)
binding.rvSpaces.visibility = View.VISIBLE
binding.dimOverlay.visibility = View.VISIBLE
@@ -834,14 +848,17 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
binding.rvFolders.alpha = 0.3f
binding.btnAddFolder.alpha = 0.3f
}
- binding.dimOverlay.animate().alpha(1f).setDuration(200)
+ binding.dimOverlay
+ .animate()
+ .alpha(1f)
+ .setDuration(200)
binding.navigationDrawerHeader.elevation = 8f
}
private fun collapseSpacesList() {
serverListCurOffset = serverListOffset
binding.spaceListMore.setImageDrawable(
- ContextCompat.getDrawable(this, R.drawable.ic_expand_more)
+ ContextCompat.getDrawable(this, R.drawable.ic_expand_more),
)
binding.rvSpaces.animate()
@@ -864,6 +881,7 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
}
// ----- Refresh & Update Methods -----
+
/**
* Updates the visibility of the current folder container.
* The container is only visible if:
@@ -978,7 +996,10 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
}
private fun refreshSpaceListAtDrawer() {
- val spaces = Space.getAll().asSequence().toList()
+ val spaces = Space.getAll().asSequence().toMutableList()
+ if (StorachaHelper.shouldEnableStorachaAccess(this)) {
+ spaces.add(Space(type = Space.Type.STORACHA.id, name = "Storacha Service"))
+ }
val hasDwebGroups = SnowbirdGroup.getAll().isNotEmpty()
mSpaceAdapter.update(spaces, hasDwebGroups)
}
@@ -1034,8 +1055,10 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
val project = getSelectedProject()
if (project != null) {
- val count = project.collections.map { it.size }
- .reduceOrNull { acc, count -> acc + count } ?: 0
+ val count =
+ project.collections
+ .map { it.size }
+ .reduceOrNull { acc, count -> acc + count } ?: 0
binding.contentMain.itemCount.text = NumberFormat.getInstance().format(count)
if (!selectModeToggle) {
binding.contentMain.itemCount.show()
@@ -1058,19 +1081,18 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
// as it doesn't make sense to show a one-option menu.
intent.putExtra(
SpaceSetupActivity.LABEL_START_DESTINATION,
- StartDestination.ADD_NEW_FOLDER.name
+ StartDestination.ADD_NEW_FOLDER.name,
)
} else {
intent.putExtra(
SpaceSetupActivity.LABEL_START_DESTINATION,
- StartDestination.ADD_FOLDER.name
+ StartDestination.ADD_FOLDER.name,
)
}
mNewFolderResultLauncher.launch(intent)
}
private fun addClicked(mediaType: AddMediaType) {
-
when {
getSelectedProject() != null -> {
if (Prefs.addMediaHint) {
@@ -1118,7 +1140,7 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
onDone = {
Prefs.addMediaHint = true
addClicked(mediaType)
- }
+ },
)
}
}
@@ -1131,13 +1153,16 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
private fun importSharedMedia(imageIntent: Intent?) {
if (imageIntent?.action != Intent.ACTION_SEND) return
val uri =
- imageIntent.data ?: imageIntent.clipData?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.uri
+ imageIntent.data ?: imageIntent.clipData
+ ?.takeIf { it.itemCount > 0 }
+ ?.getItemAt(0)
+ ?.uri
val path = uri?.path ?: return
if (path.contains(packageName)) return
mSnackBar?.show()
lifecycleScope.launch(Dispatchers.IO) {
- //When we are sharing a file to be uploaded to Save app we don't generate proof.
+ // When we are sharing a file to be uploaded to Save app we don't generate proof.
val media = Picker.import(this@MainActivity, getSelectedProject(), uri, false)
lifecycleScope.launch(Dispatchers.Main) {
mSnackBar?.dismiss()
@@ -1176,8 +1201,8 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
val hasDwebGroup = SnowbirdGroup.getAll().isNotEmpty()
val shouldShowSideMenu =
- ((Space.current != null || hasDwebGroup) && mCurrentPagerItem != mPagerAdapter.settingsIndex)
-
+ ((Space.current != null || hasDwebGroup) && mCurrentPagerItem != mPagerAdapter.settingsIndex) ||
+ StorachaHelper.shouldEnableStorachaAccess(this)
menu?.findItem(R.id.menu_folders)?.apply {
isVisible = shouldShowSideMenu
}
@@ -1193,8 +1218,8 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
return super.onPrepareOptionsMenu(menu)
}
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
+ override fun onOptionsItemSelected(item: MenuItem): Boolean =
+ when (item.itemId) {
R.id.menu_folders -> {
toggleDrawerState()
true
@@ -1202,7 +1227,6 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
else -> super.onOptionsItemSelected(item)
}
- }
// ----- Adapter Listeners -----
override fun onProjectSelected(project: Project) {
@@ -1210,9 +1234,7 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
mCurrentPagerItem = mPagerAdapter.projects.indexOf(project)
}
- override fun getSelectedProject(): Project? {
- return mPagerAdapter.getProject(mCurrentPagerItem)
- }
+ override fun getSelectedProject(): Project? = mPagerAdapter.getProject(mCurrentPagerItem)
override fun onSpaceSelected(space: Space) {
Space.current = space
@@ -1220,6 +1242,11 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
updateCurrentSpaceAtDrawer()
collapseSpacesList()
binding.drawerLayout.closeDrawer(binding.drawerContent)
+ if (space.type == Space.Type.STORACHA.id) {
+ val intent = Intent(this, SpaceSetupActivity::class.java)
+ intent.putExtra("start_destination", "STORACHA")
+ startActivity(intent)
+ }
}
override fun onAddNewSpace() {
@@ -1266,20 +1293,23 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda
uploadManagerFragment = fragment
// Observe when it gets dismissed
- fragment.lifecycle.addObserver(object : DefaultLifecycleObserver {
- override fun onDestroy(owner: LifecycleOwner) {
- uploadManagerFragment = null // Clear reference
-
- // Check if there are pending uploads
- if (Media.getByStatus(
- listOf(Media.Status.Queued, Media.Status.Uploading),
- Media.ORDER_PRIORITY
- ).isNotEmpty()
- ) {
- UploadService.startUploadService(this@MainActivity)
+ fragment.lifecycle.addObserver(
+ object : DefaultLifecycleObserver {
+ override fun onDestroy(owner: LifecycleOwner) {
+ uploadManagerFragment = null // Clear reference
+
+ // Check if there are pending uploads
+ if (Media
+ .getByStatus(
+ listOf(Media.Status.Queued, Media.Status.Uploading),
+ Media.ORDER_PRIORITY,
+ ).isNotEmpty()
+ ) {
+ UploadService.startUploadService(this@MainActivity)
+ }
}
- }
- })
+ },
+ )
}
}
}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/ExpandableSpaceList.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/ExpandableSpaceList.kt
index 04a2cae86..f649e5f77 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/ExpandableSpaceList.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/ui/components/ExpandableSpaceList.kt
@@ -121,6 +121,7 @@ fun SpaceIcon(
Space.Type.WEBDAV -> painterResource(R.drawable.ic_space_private_server)
Space.Type.INTERNET_ARCHIVE -> painterResource(R.drawable.ic_space_interent_archive)
Space.Type.RAVEN -> painterResource(R.drawable.ic_space_dweb)
+ Space.Type.STORACHA -> painterResource(R.drawable.storacha)
}
Icon(
modifier = modifier,
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt
index 38e4b5122..ccbff06d6 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraConfig.kt
@@ -12,29 +12,23 @@ data class CameraConfig(
// ===== Capture Modes =====
/** Enable photo capture functionality */
val allowVideoCapture: Boolean = true,
-
/** Enable video recording functionality */
val allowPhotoCapture: Boolean = true,
-
/** Allow capturing multiple photos/videos in one session */
val allowMultipleCapture: Boolean = false,
-
/** Enable preview functionality (should generally stay true) */
val enablePreview: Boolean = true,
-
/** Initial capture mode when camera opens */
val initialMode: CameraCaptureMode = CameraCaptureMode.PHOTO,
-
// ===== UI Controls =====
/** Show flash toggle button (only appears if camera has flash hardware) */
val showFlashToggle: Boolean = true,
-
/** Show grid overlay toggle button for composition assistance */
val showGridToggle: Boolean = true,
-
/** Show button to switch between front and back cameras */
val showCameraSwitch: Boolean = true,
-
+ // When true, uses IMG_123.jpg instead of 20250119_143045.IMG_123.jpg
+ val useCleanFilenames: Boolean = false,
// ===== Power Management =====
/**
* Override screen brightness to maximum when camera is active.
@@ -48,7 +42,6 @@ data class CameraConfig(
* Default: true (prevents automatic brightness reduction)
*/
val overrideScreenBrightness: Boolean = true,
-
/**
* Enable automatic camera pause after inactivity.
*
@@ -59,7 +52,6 @@ data class CameraConfig(
* Recommendation: Keep enabled for better battery life
*/
val enableIdleTimeout: Boolean = true,
-
/**
* Duration in seconds before camera automatically pauses (when [enableIdleTimeout] is true).
*
@@ -71,7 +63,6 @@ data class CameraConfig(
* Default: 60 seconds
*/
val idleTimeoutSeconds: Int = 60,
-
// ===== Preview Optimization =====
/**
* Camera preview resolution.
@@ -87,7 +78,6 @@ data class CameraConfig(
* Default: HD
*/
val previewResolution: PreviewResolution = PreviewResolution.HD,
-
/**
* PreviewView rendering implementation mode.
*
@@ -102,7 +92,6 @@ data class CameraConfig(
* Default: PERFORMANCE
*/
val implementationMode: ImplementationMode = ImplementationMode.PERFORMANCE,
-
// ===== Video Recording Settings =====
/**
* Video recording quality.
@@ -119,7 +108,6 @@ data class CameraConfig(
* Default: HD
*/
val videoQuality: VideoQuality = VideoQuality.HD,
-
/**
* Enable audio recording with video (requires RECORD_AUDIO permission).
*
@@ -127,7 +115,6 @@ data class CameraConfig(
* Default: true
*/
val enableAudio: Boolean = true,
-
// ===== Video Playback Optimization =====
/**
* Defer video player initialization until preview is actually shown.
@@ -140,7 +127,6 @@ data class CameraConfig(
* Default: true
*/
val enableLazyVideoLoading: Boolean = true,
-
/**
* Target buffer duration in milliseconds required to start/resume video playback.
*
@@ -152,7 +138,6 @@ data class CameraConfig(
* Default: 1500ms
*/
val videoBufferMs: Int = 1500,
-
/**
* Minimum total buffer duration for video playback (in milliseconds).
*
@@ -164,7 +149,6 @@ data class CameraConfig(
* Default: 2500ms
*/
val minVideoBufferMs: Int = 2500,
-
/**
* Maximum buffer duration for video playback (in milliseconds).
*
@@ -174,7 +158,7 @@ data class CameraConfig(
* Recommendation: 5000-10000ms
* Default: 5000ms
*/
- val maxVideoBufferMs: Int = 5000
+ val maxVideoBufferMs: Int = 5000,
) : Serializable
/**
@@ -182,7 +166,7 @@ data class CameraConfig(
*/
enum class CameraCaptureMode : Serializable {
PHOTO,
- VIDEO
+ VIDEO,
}
/**
@@ -198,7 +182,7 @@ enum class PreviewResolution : Serializable {
FHD,
/** Maximum supported resolution - Use sparingly */
- MAX
+ MAX,
}
/**
@@ -217,7 +201,7 @@ enum class ImplementationMode : Serializable {
* Uses TextureView for rendering.
* Use only if PERFORMANCE mode causes rendering issues.
*/
- COMPATIBLE
+ COMPATIBLE,
}
/**
@@ -236,5 +220,5 @@ enum class VideoQuality : Serializable {
FHD,
/** 4K - Only for high-end devices (if supported) */
- UHD
-}
\ No newline at end of file
+ UHD,
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt
index 497ea922e..8945707e8 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraScreen.kt
@@ -345,6 +345,7 @@ fun CameraScreen(
viewModel.capturePhoto(
context = context,
imageCapture = capture,
+ useCleanFilenames = config.useCleanFilenames,
onSuccess = { uri ->
AppLogger.d("Photo captured: $uri")
},
@@ -359,6 +360,7 @@ fun CameraScreen(
viewModel.startVideoRecording(
context = context,
videoCapture = capture,
+ useCleanFilenames = config.useCleanFilenames,
onSuccess = { uri ->
AppLogger.d("Video captured: $uri")
},
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt
index 051c26b4f..938c69f52 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt
@@ -93,14 +93,19 @@ class CameraViewModel : ViewModel() {
fun capturePhoto(
context: Context,
imageCapture: ImageCapture,
+ useCleanFilenames: Boolean = false,
onSuccess: (Uri) -> Unit,
onError: (Exception) -> Unit
) {
viewModelScope.launch {
try {
val filename = "IMG_${System.currentTimeMillis()}.jpg"
- val outputFile = Utility.getOutputMediaFileByCache(context, filename)
-
+ val outputFile = if (useCleanFilenames) {
+ Utility.getOutputMediaFileByCacheNoTimestamp(context, filename)
+ } else {
+ Utility.getOutputMediaFileByCache(context, filename)
+ }
+
if (outputFile == null) {
onError(Exception("Failed to create output file"))
return@launch
@@ -148,6 +153,7 @@ class CameraViewModel : ViewModel() {
fun startVideoRecording(
context: Context,
videoCapture: androidx.camera.video.VideoCapture,
+ useCleanFilenames: Boolean = false,
onSuccess: (Uri) -> Unit,
onError: (Exception) -> Unit
) {
@@ -158,8 +164,12 @@ class CameraViewModel : ViewModel() {
try {
val filename = "VID_${System.currentTimeMillis()}.mp4"
- val outputFile = Utility.getOutputMediaFileByCache(context, filename)
-
+ val outputFile = if (useCleanFilenames) {
+ Utility.getOutputMediaFileByCacheNoTimestamp(context, filename)
+ } else {
+ Utility.getOutputMediaFileByCache(context, filename)
+ }
+
if (outputFile == null) {
onError(Exception("Failed to create output file"))
return
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt
index 28a67d9d1..5f20a9cf6 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/onboarding/SpaceSetupActivity.kt
@@ -20,6 +20,7 @@ enum class StartDestination {
SPACE_LIST,
ADD_FOLDER,
ADD_NEW_FOLDER,
+ STORACHA,
ARCHIVED_FOLDER_LIST
}
@@ -85,6 +86,9 @@ class SpaceSetupActivity : BaseActivity() {
StartDestination.ADD_NEW_FOLDER -> {
navGraph.setStartDestination(R.id.fragment_create_new_folder)
}
+ StartDestination.STORACHA -> {
+ navGraph.setStartDestination(R.id.fragment_storacha)
+ }
StartDestination.ARCHIVED_FOLDER_LIST -> {
navGraph.setStartDestination(R.id.fragment_folders)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupFragment.kt
index b2ce32696..311e87a7b 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SpaceSetupFragment.kt
@@ -34,7 +34,8 @@ class SpaceSetupFragment : BaseFragment() {
// Only enable Internet Archive if not already present
val isInternetArchiveAllowed = !Space.has(Space.Type.INTERNET_ARCHIVE)
val onInternetArchiveClick = {
- val action = SpaceSetupFragmentDirections.actionFragmentSpaceSetupToInternetArchiveLogin()
+ val action =
+ SpaceSetupFragmentDirections.actionFragmentSpaceSetupToInternetArchiveLogin()
findNavController().navigate(action)
}
@@ -45,13 +46,20 @@ class SpaceSetupFragment : BaseFragment() {
startActivity(intent)
}
+ val onStorachaClicked = {
+ val action =
+ SpaceSetupFragmentDirections.actionFragmentSpaceSetupToFragmentStoracha()
+ findNavController().navigate(action)
+ }
+
SaveAppTheme {
SpaceSetupScreen(
onWebDavClick = onWebDavClick,
isInternetArchiveAllowed = isInternetArchiveAllowed,
onInternetArchiveClick = onInternetArchiveClick,
isDwebEnabled = isDwebEnabled,
- onDwebClicked = onDwebClicked
+ onDwebClicked = onDwebClicked,
+ onStorachaClicked = onStorachaClicked
)
}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt
index 8d8cc5903..2939aa3aa 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceListFragment.kt
@@ -76,10 +76,13 @@ class SpaceListFragment : BaseFragment() {
findNavController().navigate(action)
}
-
Space.Type.RAVEN -> {
// Do nothing
}
+
+ Space.Type.STORACHA -> {
+ // Do nothing
+ }
}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupScreen.kt
index e38159954..f92d0967f 100644
--- a/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupScreen.kt
+++ b/app/src/main/java/net/opendasharchive/openarchive/features/spaces/SpaceSetupScreen.kt
@@ -27,7 +27,8 @@ fun SpaceSetupScreen(
isInternetArchiveAllowed: Boolean,
onInternetArchiveClick: () -> Unit,
isDwebEnabled: Boolean,
- onDwebClicked: () -> Unit
+ onDwebClicked: () -> Unit,
+ onStorachaClicked: () -> Unit,
) {
// Use a scrollable Column to mimic ScrollView + LinearLayout
Column(
@@ -94,6 +95,14 @@ fun SpaceSetupScreen(
onClick = onDwebClicked
)
}
+
+ // WebDav option
+ ServerOptionItem(
+ iconRes = R.drawable.storacha,
+ title = stringResource(R.string.storacha),
+ subtitle = stringResource(R.string.send_directly_to_storacha_server),
+ onClick = onStorachaClicked
+ )
}
}
@@ -108,6 +117,7 @@ private fun SpaceSetupScreenPreview() {
onInternetArchiveClick = {},
isDwebEnabled = true,
onDwebClicked = {},
+ onStorachaClicked = {}
)
}
}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/SpacesUsageAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/SpacesUsageAdapter.kt
new file mode 100644
index 000000000..a1b5f5b40
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/SpacesUsageAdapter.kt
@@ -0,0 +1,83 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import net.opendasharchive.openarchive.databinding.ItemSpaceUsageBinding
+import net.opendasharchive.openarchive.services.storacha.model.SpaceUsageEntry
+
+enum class SortType {
+ NAME_ASC,
+ NAME_DESC,
+ SIZE_ASC,
+ SIZE_DESC,
+}
+
+class SpacesUsageAdapter : ListAdapter(SpaceDiffCallback()) {
+ private var originalList: List = emptyList()
+ private var currentSortType = SortType.NAME_ASC
+
+ fun setSpaces(spaces: List) {
+ originalList = spaces
+ sortAndSubmit()
+ }
+
+ fun sortBy(sortType: SortType) {
+ currentSortType = sortType
+ sortAndSubmit()
+ }
+
+ private fun sortAndSubmit() {
+ val sortedList =
+ when (currentSortType) {
+ SortType.NAME_ASC -> originalList.sortedBy { it.name.lowercase() }
+ SortType.NAME_DESC -> originalList.sortedByDescending { it.name.lowercase() }
+ SortType.SIZE_ASC -> originalList.sortedBy { it.usage.bytes }
+ SortType.SIZE_DESC -> originalList.sortedByDescending { it.usage.bytes }
+ }
+ submitList(sortedList)
+ }
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): SpaceViewHolder {
+ val binding =
+ ItemSpaceUsageBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ )
+ return SpaceViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(
+ holder: SpaceViewHolder,
+ position: Int,
+ ) {
+ holder.bind(getItem(position))
+ }
+
+ class SpaceViewHolder(
+ private val binding: ItemSpaceUsageBinding,
+ ) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(space: SpaceUsageEntry) {
+ binding.tvSpaceName.text = space.name
+ binding.tvSpaceUsage.text = space.usage.human
+ }
+ }
+
+ private class SpaceDiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: SpaceUsageEntry,
+ newItem: SpaceUsageEntry,
+ ): Boolean = oldItem.spaceDid == newItem.spaceDid
+
+ override fun areContentsTheSame(
+ oldItem: SpaceUsageEntry,
+ newItem: SpaceUsageEntry,
+ ): Boolean = oldItem == newItem
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaAccountDetailsFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaAccountDetailsFragment.kt
new file mode 100644
index 000000000..785b64dbe
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaAccountDetailsFragment.kt
@@ -0,0 +1,301 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.isVisible
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import androidx.recyclerview.widget.LinearLayoutManager
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.FragmentStorachaAccountDetailsBinding
+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.services.storacha.util.StorachaAccountManager
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaAccountDetailsViewModel
+import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets
+import net.opendasharchive.openarchive.util.extensions.toggle
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import timber.log.Timber
+
+class StorachaAccountDetailsFragment : BaseFragment() {
+ private lateinit var binding: FragmentStorachaAccountDetailsBinding
+ private val viewModel: StorachaAccountDetailsViewModel by viewModel()
+ private val args: StorachaAccountDetailsFragmentArgs by navArgs()
+ private lateinit var spacesAdapter: SpacesUsageAdapter
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ binding = FragmentStorachaAccountDetailsBinding.inflate(layoutInflater)
+
+ binding.buttonBar.applyEdgeToEdgeInsets(
+ typeMask = WindowInsetsCompat.Type.navigationBars(),
+ ) { insets ->
+ bottomMargin = insets.bottom
+ }
+
+ return binding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setupRecyclerView()
+ setupObservers()
+ setupFilterButtons()
+ loadAccountData()
+
+ binding.btLogout.setOnClickListener {
+ performLogout()
+ }
+ }
+
+ private fun setupRecyclerView() {
+ spacesAdapter = SpacesUsageAdapter()
+ binding.rvSpaces.apply {
+ adapter = spacesAdapter
+ layoutManager = LinearLayoutManager(requireContext())
+ }
+ }
+
+ private fun setupObservers() {
+ viewModel.accountUsage.observe(viewLifecycleOwner) { result ->
+ result
+ .onSuccess { accountUsage ->
+ updateUI(accountUsage)
+ }.onFailure { error ->
+ // Handle error - could show a Toast or error message
+ binding.tvPackage.text = "Error loading data"
+ binding.tvUtilisation.text = "Unable to fetch usage"
+ }
+ }
+
+ viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
+ binding.loadingContainer.toggle(isLoading)
+ if (isLoading) {
+ binding.loadingText.text = getString(R.string.loading_usage)
+ }
+ }
+
+ viewModel.logoutResult.observe(viewLifecycleOwner) { result ->
+ result
+ .onSuccess {
+ val accountManager = StorachaAccountManager(requireContext())
+ accountManager.removeAccount(args.email)
+ val action =
+ StorachaAccountDetailsFragmentDirections.actionFragmentStorachaAccountDetailsToFragmentStoracha()
+ findNavController().navigate(action)
+ }.onFailure {
+ // Even if API logout fails, remove locally and navigate back
+ val accountManager = StorachaAccountManager(requireContext())
+ accountManager.removeAccount(args.email)
+ val action =
+ StorachaAccountDetailsFragmentDirections.actionFragmentStorachaAccountDetailsToFragmentStoracha()
+ findNavController().navigate(action)
+ }
+ }
+
+ // Observe session expiration
+ viewModel.sessionExpired.observe(viewLifecycleOwner) { expired ->
+ if (expired) {
+ showSessionExpiredDialog()
+ }
+ }
+ }
+
+ private fun loadAccountData() {
+ binding.etEmail.setText(args.email)
+ viewModel.loadAccountUsage(args.sessionId)
+ }
+
+ private fun updateUI(accountUsage: net.opendasharchive.openarchive.services.storacha.model.AccountUsageResponse) {
+ // Log planProduct for debugging
+ Timber.d("Account plan product: ${accountUsage.planProduct}")
+
+ // Check if we have valid plan information
+ val hasPlanInfo =
+ !accountUsage.planProduct.isNullOrBlank() &&
+ (
+ accountUsage.planProduct.contains("starter", ignoreCase = true) ||
+ accountUsage.planProduct.contains("free", ignoreCase = true) ||
+ accountUsage.planProduct.contains("lite", ignoreCase = true) ||
+ accountUsage.planProduct.contains("basic", ignoreCase = true) ||
+ accountUsage.planProduct.contains(
+ "business",
+ ignoreCase = true,
+ ) ||
+ accountUsage.planProduct.contains("pro", ignoreCase = true) ||
+ accountUsage.planProduct.contains(
+ "enterprise",
+ ignoreCase = true,
+ )
+ )
+
+ if (hasPlanInfo) {
+ // Show plan-based information
+ val (planName, storageLimit, monthlyCost, additionalCost) =
+ when {
+ accountUsage.planProduct.contains(
+ "starter",
+ ignoreCase = true,
+ ) || accountUsage.planProduct.contains("free", ignoreCase = true) ->
+ Tuple4("Starter", 5L * 1024 * 1024 * 1024, 0.0, 0.15) // 5GB
+
+ accountUsage.planProduct.contains(
+ "lite",
+ ignoreCase = true,
+ ) ||
+ accountUsage.planProduct.contains(
+ "basic",
+ ignoreCase = true,
+ )
+ ->
+ Tuple4("Lite", 100L * 1024 * 1024 * 1024, 10.0, 0.05) // 100GB
+
+ else -> // business/pro/enterprise
+ Tuple4("Business", 2L * 1024 * 1024 * 1024 * 1024, 100.0, 0.03) // 2TB
+ }
+
+ Timber.d("Showing plan info: $planName with ${formatBytes(storageLimit)} storage limit")
+
+ // Show and populate plan information
+ binding.tvPackage.isVisible = true
+ binding.tvAllocationBilling.isVisible = false
+ binding.piUsage.isVisible = false
+
+ val packageText = "$planName Plan"
+// if (monthlyCost > 0) {
+// "$planName Plan - $${monthlyCost.toInt()}/month"
+// } else {
+// "$planName Plan - Free"
+// }
+ binding.tvPackage.text = packageText
+
+ val storageFormatted = formatBytes(storageLimit)
+ val additionalCostFormatted = String.format("$%.3f", additionalCost)
+ val allocationText =
+ if (monthlyCost == 0.0) {
+ "$storageFormatted free, then $additionalCostFormatted/GB"
+ } else {
+ "$storageFormatted included, then $additionalCostFormatted/GB"
+ }
+ binding.tvAllocationBilling.text = allocationText
+
+ // Calculate and show usage with plan context
+ val usagePercentage =
+ if (storageLimit > 0) {
+ ((accountUsage.totalUsage.bytes.toDouble() / storageLimit.toDouble()) * 100)
+ .toInt()
+ .coerceIn(0, 100)
+ } else {
+ 0
+ }
+
+ val used = formatBytes(accountUsage.totalUsage.bytes)
+ val total = formatBytes(storageLimit)
+ binding.tvUtilisation.text = "$used used"
+ binding.piUsage.progress = usagePercentage
+ } else {
+ // No plan information available - hide plan elements and show only usage
+ Timber.d("No valid plan info available, showing usage only")
+
+ binding.tvPackage.isVisible = false
+ binding.tvAllocationBilling.isVisible = false
+ binding.piUsage.isVisible = false
+
+ // Show only the human-readable usage amount
+ binding.tvUtilisation.text = "Used: ${accountUsage.totalUsage.human}"
+ }
+
+ // Display spaces list if available
+ if (accountUsage.spaces.isNotEmpty()) {
+ binding.tvSpacesHeader.isVisible = true
+ binding.llFilterButtons.isVisible = true
+ binding.rvSpaces.isVisible = true
+ spacesAdapter.setSpaces(accountUsage.spaces)
+ } else {
+ binding.tvSpacesHeader.isVisible = false
+ binding.llFilterButtons.isVisible = false
+ binding.rvSpaces.isVisible = false
+ }
+ }
+
+ private fun setupFilterButtons() {
+ var isNameSortAscending = true
+ var isSizeSortAscending = false
+
+ binding.btnSortName.setOnClickListener {
+ val sortType = if (isNameSortAscending) SortType.NAME_ASC else SortType.NAME_DESC
+ spacesAdapter.sortBy(sortType)
+ isNameSortAscending = !isNameSortAscending
+
+ // Update button text to show current sort direction
+ binding.btnSortName.text = if (isNameSortAscending) "Name ↑" else "Name ↓"
+ binding.btnSortSize.text = "Sort by Size"
+ }
+
+ binding.btnSortSize.setOnClickListener {
+ val sortType = if (isSizeSortAscending) SortType.SIZE_ASC else SortType.SIZE_DESC
+ spacesAdapter.sortBy(sortType)
+ isSizeSortAscending = !isSizeSortAscending
+
+ // Update button text to show current sort direction
+ binding.btnSortSize.text = if (isSizeSortAscending) "Size ↑" else "Size ↓"
+ binding.btnSortName.text = "Sort by Name"
+ }
+ }
+
+ private fun formatBytes(bytes: Long): String {
+ val units = arrayOf("B", "KB", "MB", "GB", "TB")
+ var size = bytes.toDouble()
+ var unitIndex = 0
+
+ while (size >= 1024 && unitIndex < units.size - 1) {
+ size /= 1024
+ unitIndex++
+ }
+
+ return if (size == size.toLong().toDouble()) {
+ "${size.toLong()}${units[unitIndex]}"
+ } else {
+ String.format("%.1f%s", size, units[unitIndex])
+ }
+ }
+
+ // Helper class for tuple-like functionality
+ private data class Tuple4(
+ val first: A,
+ val second: B,
+ val third: C,
+ val fourth: D,
+ )
+
+ private fun performLogout() {
+ dialogManager.showDialog(dialogManager.requireResourceProvider()) {
+ type = DialogType.Warning
+ title = UiText.StringResource(R.string.logout)
+ message = UiText.StringResource(R.string.logout_confirmation)
+ positiveButton {
+ text = UiText.StringResource(R.string.logout)
+ action = {
+ viewModel.logout(args.sessionId)
+ }
+ }
+ neutralButton {
+ text = UiText.StringResource(R.string.action_cancel)
+ }
+ }
+ }
+
+ override fun getToolbarTitle(): String = getString(R.string.account)
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaBrowseAccountsAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaBrowseAccountsAdapter.kt
new file mode 100644
index 000000000..df5b29ff5
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaBrowseAccountsAdapter.kt
@@ -0,0 +1,72 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.StorachaDidRowBinding
+
+class StorachaBrowseAccountsAdapter(
+ private val accounts: List = emptyList(),
+ private val isDid: Boolean,
+ private val onClick: (account: Account) -> Unit,
+) : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): AccountViewHolder {
+ val binding =
+ StorachaDidRowBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return AccountViewHolder(binding, onClick)
+ }
+
+ override fun onBindViewHolder(
+ holder: AccountViewHolder,
+ position: Int,
+ ) {
+ holder.bind(accounts[position])
+ }
+
+ override fun getItemCount(): Int = accounts.size
+
+ inner class AccountViewHolder(
+ private val binding: StorachaDidRowBinding,
+ private val onClick: (account: Account) -> Unit,
+ ) : RecyclerView.ViewHolder(
+ binding.root,
+ ) {
+ fun bind(account: Account) {
+ if (!isDid) {
+ val icon =
+ ContextCompat.getDrawable(binding.icon.context, R.drawable.ic_account)
+ icon?.setTint(
+ ContextCompat.getColor(
+ binding.icon.context,
+ R.color.colorOnBackground,
+ ),
+ )
+ binding.icon.setImageDrawable(icon)
+ } else {
+ binding.icon.visibility = View.GONE
+ binding.rvTick.setImageResource(R.drawable.ic_trash)
+ binding.rvTick.imageTintList =
+ android.content.res.ColorStateList
+ .valueOf(ContextCompat.getColor(binding.rvTick.context, R.color.red))
+ }
+ binding.didKey.text = account.email
+ binding.rvTick.setOnClickListener {
+ onClick.invoke(account)
+ }
+ binding.root.setOnClickListener {
+ onClick.invoke(account)
+ }
+ }
+ }
+}
+
+data class Account(
+ val email: String,
+ val sessionId: String,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaBrowseFilesAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaBrowseFilesAdapter.kt
new file mode 100644
index 000000000..9d3e90b3a
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaBrowseFilesAdapter.kt
@@ -0,0 +1,51 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.StorachaDidRowBinding
+import net.opendasharchive.openarchive.services.storacha.model.UploadEntry
+
+class StorachaBrowseFilesAdapter(
+ private val files: List = emptyList(),
+ private val onClick: (file: UploadEntry) -> Unit,
+) : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): FileViewHolder {
+ val binding =
+ StorachaDidRowBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return FileViewHolder(binding, onClick)
+ }
+
+ override fun onBindViewHolder(
+ holder: FileViewHolder,
+ position: Int,
+ ) {
+ holder.bind(files[position])
+ }
+
+ override fun getItemCount(): Int = files.size
+
+ inner class FileViewHolder(
+ private val binding: StorachaDidRowBinding,
+ private val onClick: (file: UploadEntry) -> Unit,
+ ) : RecyclerView.ViewHolder(
+ binding.root,
+ ) {
+ fun bind(file: UploadEntry) {
+ val icon = ContextCompat.getDrawable(binding.icon.context, R.drawable.ic_unknown_file)
+ icon?.setTint(ContextCompat.getColor(binding.icon.context, R.color.colorOnBackground))
+ binding.icon.setImageDrawable(icon)
+ binding.didKey.text = file.cid
+ binding.rvTick.visibility = View.GONE
+ binding.root.setOnClickListener {
+ onClick.invoke(file)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaBrowseSpacesAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaBrowseSpacesAdapter.kt
new file mode 100644
index 000000000..b4a8e449d
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaBrowseSpacesAdapter.kt
@@ -0,0 +1,55 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.StorachaSpaceRowBinding
+import net.opendasharchive.openarchive.services.storacha.model.SpaceInfo
+
+class StorachaBrowseSpacesAdapter(
+ private val spaces: List = emptyList(),
+ private val onClick: (space: SpaceInfo) -> Unit,
+) : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): SpaceViewHolder {
+ val binding =
+ StorachaSpaceRowBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return SpaceViewHolder(binding, onClick)
+ }
+
+ override fun onBindViewHolder(
+ holder: SpaceViewHolder,
+ position: Int,
+ ) {
+ holder.bind(spaces[position])
+ }
+
+ override fun getItemCount(): Int = spaces.size
+
+ inner class SpaceViewHolder(
+ private val binding: StorachaSpaceRowBinding,
+ private val onClick: (space: SpaceInfo) -> Unit,
+ ) : RecyclerView.ViewHolder(
+ binding.root,
+ ) {
+ fun bind(space: SpaceInfo) {
+ val icon = ContextCompat.getDrawable(binding.icon.context, R.drawable.ic_folder_new)
+ icon?.setTint(ContextCompat.getColor(binding.icon.context, R.color.colorOnBackground))
+ binding.rvTick.visibility = View.VISIBLE
+ binding.icon.setImageDrawable(icon)
+ binding.name.text = space.name
+ binding.didKey.text = space.did
+ binding.rvTick.setOnClickListener {
+ onClick.invoke(space)
+ }
+ binding.root.setOnClickListener {
+ onClick.invoke(space)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaBrowseSpacesFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaBrowseSpacesFragment.kt
new file mode 100644
index 000000000..75535e773
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaBrowseSpacesFragment.kt
@@ -0,0 +1,126 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.LinearLayoutManager
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.FragmentStorachaBrowseSpacesBinding
+import net.opendasharchive.openarchive.features.core.BaseFragment
+import net.opendasharchive.openarchive.services.storacha.util.DidManager
+import net.opendasharchive.openarchive.services.storacha.util.StorachaAccountManager
+import net.opendasharchive.openarchive.services.storacha.util.StorachaHelper
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaBrowseSpacesViewModel
+import net.opendasharchive.openarchive.util.extensions.toggle
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class StorachaBrowseSpacesFragment : BaseFragment() {
+ private lateinit var mBinding: FragmentStorachaBrowseSpacesBinding
+ private val viewModel: StorachaBrowseSpacesViewModel by viewModel()
+
+ // Track if we're doing a pull-to-refresh to avoid dual loading indicators
+ private var isPullToRefresh = false
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ mBinding = FragmentStorachaBrowseSpacesBinding.inflate(layoutInflater)
+ mBinding.rvFolderList.layoutManager = LinearLayoutManager(requireContext())
+
+ // Setup swipe refresh
+ mBinding.swipeRefreshLayout.setOnRefreshListener {
+ isPullToRefresh = true
+ refreshSpaces()
+ }
+
+ return mBinding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ ViewCompat.setOnApplyWindowInsetsListener(mBinding.rvFolderList) { view, windowInsets ->
+ val insets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars())
+
+ view.updatePadding(
+ bottom = insets.bottom + view.paddingBottom
+ )
+
+ windowInsets
+ }
+
+ val did = DidManager(requireContext()).getOrCreateDid()
+ val accountManager = StorachaAccountManager(requireContext())
+ val currentAccount = accountManager.getCurrentAccount()
+ val sessionId = currentAccount?.sessionId ?: ""
+ viewModel.loadSpaces(did, sessionId)
+
+ viewModel.loading.observe(viewLifecycleOwner) { isLoading ->
+ // Only show center loading if it's not a pull-to-refresh
+ mBinding.loadingContainer.toggle(isLoading && !isPullToRefresh)
+
+ // Hide swipe refresh when loading is complete
+ if (!isLoading) {
+ mBinding.swipeRefreshLayout.isRefreshing = false
+ isPullToRefresh = false // Reset the flag
+ }
+ }
+
+ viewModel.spaces.observe(viewLifecycleOwner) { list ->
+ mBinding.projectsEmpty.toggle(list.isEmpty())
+
+ // Store space count in SharedPreferences for access checks
+ // Only count spaces where user is not admin (joined spaces, not owned)
+ val joinedSpaceCount = list.count { !it.isAdmin }
+ StorachaHelper.updateSpaceCount(requireContext(), joinedSpaceCount)
+
+ mBinding.rvFolderList.adapter =
+ StorachaBrowseSpacesAdapter(list) { space ->
+ val action =
+ StorachaBrowseSpacesFragmentDirections.actionFragmentStorachaBrowseSpacesToFragmentStorachaMedia(
+ spaceId = space.did,
+ spaceName = space.name,
+ sessionId = if (space.isAdmin) sessionId else "",
+ isAdmin = space.isAdmin,
+ )
+ findNavController().navigate(action)
+ }
+ }
+
+ // Observe session expiration
+ // For spaces list, allow user to stay and browse delegated spaces
+ viewModel.sessionExpired.observe(viewLifecycleOwner) { expired ->
+ if (expired) {
+ showSessionExpiredWithStayOption {
+ // Clear the flag so dialog doesn't keep showing
+ viewModel.clearSessionExpired()
+
+ // Refresh spaces list with only DID (no session)
+ // This will show only delegated spaces
+ val did = DidManager(requireContext()).getOrCreateDid()
+ viewModel.loadSpaces(did, "")
+ }
+ }
+ }
+ }
+
+ private fun refreshSpaces() {
+ val did = DidManager(requireContext()).getOrCreateDid()
+ val accountManager = StorachaAccountManager(requireContext())
+ val currentAccount = accountManager.getCurrentAccount()
+ val sessionId = currentAccount?.sessionId ?: ""
+ viewModel.loadSpaces(did, sessionId)
+ }
+
+ override fun getToolbarTitle(): String = getString(R.string.spaces)
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaClientQRFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaClientQRFragment.kt
new file mode 100644
index 000000000..78081ab7b
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaClientQRFragment.kt
@@ -0,0 +1,75 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.core.view.WindowInsetsCompat
+import androidx.navigation.fragment.findNavController
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.FragmentStorachaClientQrBinding
+import net.opendasharchive.openarchive.extensions.asQRCode
+import net.opendasharchive.openarchive.features.core.BaseFragment
+import net.opendasharchive.openarchive.services.storacha.util.DidManager
+import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets
+
+class StorachaClientQRFragment : BaseFragment() {
+ private lateinit var viewBinding: FragmentStorachaClientQrBinding
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ viewBinding = FragmentStorachaClientQrBinding.inflate(inflater)
+
+ viewBinding.buttonBar.applyEdgeToEdgeInsets(
+ typeMask = WindowInsetsCompat.Type.navigationBars(),
+ ) { insets ->
+ bottomMargin = insets.bottom
+ }
+
+ return viewBinding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ // Generate and display DID as QR code
+ val userDid = DidManager(requireContext()).getOrCreateDid()
+ val qrCode = userDid.asQRCode(size = 1024)
+ viewBinding.qrCode.setImageBitmap(qrCode)
+
+ // Display the DID text for easy copying
+ viewBinding.tvDid.text = userDid
+
+ // Set up copy button click listener
+ viewBinding.ivCopyDid.setOnClickListener {
+ copyToClipboard(userDid)
+ }
+
+ // Set up button click listener to navigate to spaces
+ viewBinding.btnContinue
+ .setOnClickListener {
+ val action =
+ StorachaClientQRFragmentDirections.actionFragmentStorachaClientQrToFragmentStorachaBrowseSpaces()
+ findNavController().navigate(action)
+ }
+ }
+
+ override fun getToolbarTitle(): String = getString(R.string.join_space)
+
+ private fun copyToClipboard(text: String) {
+ val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText("DID", text)
+ clipboard.setPrimaryClip(clip)
+ Toast.makeText(requireContext(), getString(R.string.copy_did), Toast.LENGTH_SHORT).show()
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaContentPickerFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaContentPickerFragment.kt
new file mode 100644
index 000000000..3399b1dca
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaContentPickerFragment.kt
@@ -0,0 +1,56 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.content.DialogInterface
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import net.opendasharchive.openarchive.databinding.FragmentStorachaContentPickerBinding
+
+class StorachaContentPickerFragment(
+ private val onContentPicked: (StorachaContentType) -> Unit,
+) : BottomSheetDialogFragment() {
+ private var _binding: FragmentStorachaContentPickerBinding? = null
+ private val binding get() = _binding!!
+
+ companion object {
+ const val TAG = "ModalBottomSheet-StorachaContentPickerFragment"
+ const val KEY_DISMISS = "StorachaContentPickerFragment.Dismiss"
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ _binding = FragmentStorachaContentPickerBinding.inflate(inflater, container, false)
+
+ binding.actionUploadCamera.setOnClickListener {
+ onContentPicked(StorachaContentType.CAMERA)
+ dismiss()
+ }
+
+ binding.actionUploadFiles.setOnClickListener {
+ onContentPicked(StorachaContentType.FILES)
+ dismiss()
+ }
+
+ return binding.root
+ }
+
+ override fun onDismiss(dialog: DialogInterface) {
+ parentFragmentManager.setFragmentResult(KEY_DISMISS, Bundle())
+ super.onDismiss(dialog)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
+
+enum class StorachaContentType {
+ CAMERA,
+ FILES,
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaDIDAccessFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaDIDAccessFragment.kt
new file mode 100644
index 000000000..117ccfc00
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaDIDAccessFragment.kt
@@ -0,0 +1,218 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.content.pm.ActivityInfo
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
+import androidx.core.view.WindowInsetsCompat
+import androidx.navigation.fragment.findNavController
+import com.journeyapps.barcodescanner.CaptureActivity
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanIntentResult
+import com.journeyapps.barcodescanner.ScanOptions
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.FragmentStorachaDidAccessBinding
+import net.opendasharchive.openarchive.features.core.BaseFragment
+import net.opendasharchive.openarchive.services.storacha.util.Ed25519Utils
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaDIDAccessViewModel
+import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets
+import net.opendasharchive.openarchive.util.extensions.toggle
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class StorachaDIDAccessFragment : BaseFragment() {
+ private lateinit var binding: FragmentStorachaDidAccessBinding
+ private lateinit var qrLauncher: ActivityResultLauncher
+ private val viewModel: StorachaDIDAccessViewModel by viewModel()
+
+ private val spaceId: String by lazy { arguments?.getString("space_id") ?: "" }
+ private val sessionId: String by lazy { arguments?.getString("session_id") ?: "" }
+ private val existingDids: Array by lazy {
+ arguments?.getStringArray("existing_dids") ?: emptyArray()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ binding = FragmentStorachaDidAccessBinding.inflate(layoutInflater)
+
+ // Register the QR result launcher
+ qrLauncher =
+ registerForActivityResult(ScanContract()) { result: ScanIntentResult ->
+ if (result.contents != null) {
+ val scannedText = result.contents.trim()
+ binding.tvDid.setText(scannedText)
+ // Button state will be updated automatically by TextWatcher
+
+ // Show validation feedback
+ when {
+ !Ed25519Utils.isValidDid(scannedText) -> {
+ Toast
+ .makeText(
+ requireContext(),
+ getString(R.string.invalid_did_format_please_scan_a_valid_did_key_format_did_key_z),
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+
+ existingDids.contains(scannedText) -> {
+ Toast
+ .makeText(
+ requireContext(),
+ getString(R.string.did_already_added),
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ }
+ }
+ }
+
+ binding.btOk.setOnClickListener {
+ val didText =
+ binding.tvDid.text
+ .toString()
+ .trim()
+
+ when {
+ didText.isEmpty() -> {
+ Toast
+ .makeText(requireContext(), R.string.did_already_added, Toast.LENGTH_SHORT)
+ .show()
+// binding.tvDid.error = "DID is required"
+ }
+
+ !Ed25519Utils.isValidDid(didText) -> {
+ Toast
+ .makeText(
+ requireContext(),
+ getString(R.string.invalid_did_format_please_scan_a_valid_did_key_format_did_key_z),
+ Toast.LENGTH_LONG,
+ ).show()
+// binding.tvDid.error = "Invalid DID format"
+ }
+
+ existingDids.contains(didText) -> {
+ Toast
+ .makeText(
+ requireContext(),
+ getString(R.string.did_already_added),
+ Toast.LENGTH_LONG,
+ ).show()
+// binding.tvDid.error = "DID already added"
+ }
+
+ else -> {
+// binding.tvDid.error = null
+ viewModel.createDelegation(
+ sessionId = sessionId,
+ userDid = didText,
+ spaceDid = spaceId,
+ )
+ }
+ }
+ }
+
+ binding.ivQrScanner.setOnClickListener {
+ val options = ScanOptions()
+ options.setOrientationLocked(true)
+ options.setPrompt(getString(R.string.scan_did_key))
+ options.setBeepEnabled(true)
+ options.setCaptureActivity(PortraitCaptureActivity::class.java)
+ qrLauncher.launch(options)
+ }
+
+ // Add TextWatcher to validate DID and enable/disable button
+ binding.tvDid.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
+ override fun afterTextChanged(s: Editable?) {
+ validateAndUpdateButton()
+ }
+ })
+
+ // Initially disable button
+ validateAndUpdateButton()
+
+ return binding.root
+ }
+
+ private fun validateAndUpdateButton() {
+ val didText = binding.tvDid.text.toString().trim()
+ // Only check format validity, not duplicates
+ // Let user click button to see duplicate error message
+ val isValid = didText.isNotEmpty() &&
+ Ed25519Utils.isValidDid(didText)
+
+ // Only enable if valid AND not loading
+ binding.btOk.isEnabled = isValid && (viewModel.loading.value != true)
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.buttonBar.applyEdgeToEdgeInsets(
+ typeMask = WindowInsetsCompat.Type.tappableElement(),
+ ) { insets ->
+ bottomMargin = insets.bottom
+ }
+
+ setupObservers()
+ }
+
+ private fun setupObservers() {
+ viewModel.loading.observe(viewLifecycleOwner) { isLoading ->
+ // Show/hide loading indicator
+ binding.loadingContainer.toggle(isLoading)
+
+ // Update button state based on both loading and validation
+ validateAndUpdateButton()
+ }
+
+ viewModel.success.observe(viewLifecycleOwner) { success ->
+ if (success) {
+ Toast
+ .makeText(
+ requireContext(),
+ getString(R.string.did_access_granted_successfully),
+ Toast.LENGTH_SHORT,
+ ).show()
+ findNavController().navigateUp()
+ }
+ }
+
+ viewModel.error.observe(viewLifecycleOwner) { errorMessage ->
+ if (!errorMessage.isNullOrEmpty()) {
+ Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_LONG).show()
+ }
+ }
+
+ // Observe session expiration
+ viewModel.sessionExpired.observe(viewLifecycleOwner) { expired ->
+ if (expired) {
+ showSessionExpiredDialog()
+ }
+ }
+ }
+
+ override fun getToolbarTitle() = getString(R.string.add_did)
+
+ override fun getToolbarSubtitle(): String? = null
+
+ override fun shouldShowBackButton() = true
+}
+
+class PortraitCaptureActivity : CaptureActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ super.onCreate(savedInstanceState)
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaEmailVerificationSentFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaEmailVerificationSentFragment.kt
new file mode 100644
index 000000000..bbfc319d8
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaEmailVerificationSentFragment.kt
@@ -0,0 +1,143 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.os.Bundle
+import android.text.Html
+import android.text.method.LinkMovementMethod
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.OnBackPressedCallback
+import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.Observer
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.FragmentStorachaEmailVerificationSentBinding
+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.services.storacha.util.StorachaAccountManager
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaEmailVerificationSentViewModel
+import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.core.parameter.parametersOf
+
+class StorachaEmailVerificationSentFragment : BaseFragment() {
+ private lateinit var mBinding: FragmentStorachaEmailVerificationSentBinding
+ private val args: StorachaEmailVerificationSentFragmentArgs by navArgs()
+ private val viewModel: StorachaEmailVerificationSentViewModel by viewModel {
+ val accountManager = StorachaAccountManager(requireContext())
+ val currentAccount = accountManager.getCurrentAccount()
+ val sessionId = currentAccount?.sessionId ?: ""
+ parametersOf(requireActivity().application, sessionId)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ mBinding = FragmentStorachaEmailVerificationSentBinding.inflate(inflater)
+ return mBinding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ mBinding.root.applyEdgeToEdgeInsets(
+ typeMask = WindowInsetsCompat.Type.navigationBars(),
+ ) { insets ->
+ bottomMargin = insets.bottom
+ }
+
+ // Disable hardware back button
+ val backPressCallback = object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ // Do nothing - block back navigation
+ }
+ }
+ requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressCallback)
+
+ // Display the email that was passed as an argument
+ mBinding.emailId.text = getString(R.string.sent_to, args.email)
+
+ // Setup change email link
+ mBinding.tvChangeEmailLink.text = Html.fromHtml(getString(R.string.change_email_link), Html.FROM_HTML_MODE_LEGACY)
+ mBinding.tvChangeEmailLink.movementMethod = LinkMovementMethod.getInstance()
+ mBinding.tvChangeEmailLink.setOnClickListener {
+ navigateBackToLogin()
+ }
+
+ viewModel.navigateNext.observe(
+ viewLifecycleOwner,
+ Observer {
+ val action =
+ StorachaEmailVerificationSentFragmentDirections
+ .actionFragmentStorachaEmailVerificationSentToFragmentStorachaSpaceSetupSuccess()
+ findNavController().navigate(action)
+ },
+ )
+
+ viewModel.showTimeoutDialog.observe(
+ viewLifecycleOwner,
+ Observer {
+ showTimeoutDialog()
+ },
+ )
+ }
+
+ override fun onResume() {
+ super.onResume()
+ viewModel.resumePolling()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ viewModel.pausePolling()
+ }
+
+ override fun getToolbarTitle() = getString(R.string.email_verification)
+
+ override fun shouldShowBackButton() = false
+
+ private fun showTimeoutDialog() {
+ dialogManager.showDialog(dialogManager.requireResourceProvider()) {
+ type = DialogType.Warning
+ title = UiText.DynamicString("Verification Timeout")
+ message =
+ UiText.DynamicString(
+ "We didn't receive confirmation of your email verification. Please check your email and try again, or return to login.",
+ )
+ positiveButton {
+ text = UiText.DynamicString("Try Again")
+ action = { viewModel.tryAgain() }
+ }
+ neutralButton {
+ text = UiText.DynamicString("Back to Login")
+ action = { navigateBackToLogin() }
+ }
+ }
+ }
+
+ private fun navigateBackToLogin() {
+ // Stop polling to prevent background requests
+ viewModel.pausePolling()
+
+ // Clear the current unverified account to prevent auto-navigation back
+ val accountManager = StorachaAccountManager(requireContext())
+ val currentAccount = accountManager.getCurrentAccount()
+
+ // Remove the current unverified account completely to start fresh
+ currentAccount?.email?.let { email ->
+ accountManager.removeAccount(email)
+ }
+
+ // Navigate directly to login screen with clear navigation stack
+ val action = StorachaEmailVerificationSentFragmentDirections.actionFragmentStorachaEmailVerificationSentToFragmentStorachaLogin()
+ findNavController().navigate(action)
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaFragment.kt
new file mode 100644
index 000000000..447180464
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaFragment.kt
@@ -0,0 +1,92 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.os.Bundle
+import android.text.method.LinkMovementMethod
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.text.HtmlCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.navigation.fragment.findNavController
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.FragmentStorachaBinding
+import net.opendasharchive.openarchive.features.core.BaseFragment
+import net.opendasharchive.openarchive.services.storacha.util.StorachaAccountManager
+import net.opendasharchive.openarchive.services.storacha.util.StorachaHelper
+import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets
+
+class StorachaFragment : BaseFragment() {
+ private lateinit var viewBinding: FragmentStorachaBinding
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ viewBinding = FragmentStorachaBinding.inflate(inflater)
+
+ return viewBinding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewBinding.root.applyEdgeToEdgeInsets(
+ typeMask = WindowInsetsCompat.Type.navigationBars(),
+ ) { insets ->
+ bottomMargin = insets.bottom
+ }
+
+ viewBinding.tvStorachaDisclaimer.text =
+ HtmlCompat.fromHtml(getString(R.string.storacha_disclaimer), HtmlCompat.FROM_HTML_MODE_LEGACY)
+ viewBinding.tvStorachaDisclaimer.movementMethod = LinkMovementMethod.getInstance()
+
+ viewBinding.btnJoinSpaces.setOnClickListener {
+ val action =
+ StorachaFragmentDirections.actionFragmentStorachaToFragmentStorachaClientQr("Test")
+ findNavController().navigate(action)
+ }
+
+ viewBinding.btnMySpaces.setOnClickListener {
+ val action =
+ StorachaFragmentDirections.actionFragmentStorachaToFragmentStorachaBrowseSpaces()
+ findNavController().navigate(action)
+ }
+
+ viewBinding.btnManageAccounts.setOnClickListener {
+ navigateToAccountManagement()
+ }
+
+ updateButtonStates()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // Update button states when returning to this fragment
+ updateButtonStates()
+ }
+
+ private fun updateButtonStates() {
+ // Enable "My Spaces" button if user has logged-in accounts OR has access to spaces
+ val shouldEnable = StorachaHelper.shouldEnableStorachaAccess(requireContext())
+
+ viewBinding.btnMySpaces.isEnabled = shouldEnable
+ viewBinding.btnMySpaces.alpha = if (shouldEnable) 1.0f else 0.5f
+ }
+
+ override fun getToolbarTitle(): String = getString(R.string.storacha)
+
+ private fun navigateToAccountManagement() {
+ val accountManager = StorachaAccountManager(requireContext())
+ val action =
+ if (accountManager.hasLoggedInAccounts()) {
+ StorachaFragmentDirections.actionFragmentStorachaToFragmentStorachaAccounts()
+ } else {
+ StorachaFragmentDirections.actionFragmentStorachaToFragmentStorachaLogin()
+ }
+ findNavController().navigate(action)
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaLoginFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaLoginFragment.kt
new file mode 100644
index 000000000..151f7ad40
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaLoginFragment.kt
@@ -0,0 +1,216 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.result.contract.ActivityResultContracts
+import android.text.Editable
+import android.text.Html
+import android.text.TextWatcher
+import android.text.method.LinkMovementMethod
+import android.view.KeyEvent
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.widget.Toast
+import androidx.core.net.toUri
+import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.Observer
+import androidx.navigation.fragment.findNavController
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.FragmentStorachaLoginBinding
+import net.opendasharchive.openarchive.features.core.BaseFragment
+import net.opendasharchive.openarchive.services.storacha.util.DidManager
+import net.opendasharchive.openarchive.services.storacha.util.StorachaAccountManager
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaLoginViewModel
+import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets
+import net.opendasharchive.openarchive.util.extensions.toggle
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class StorachaLoginFragment : BaseFragment() {
+ private lateinit var viewBinding: FragmentStorachaLoginBinding
+ private val viewModel: StorachaLoginViewModel by viewModel()
+
+ private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ // No action needed on return
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ viewBinding = FragmentStorachaLoginBinding.inflate(inflater)
+ return viewBinding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewBinding.buttonBar.applyEdgeToEdgeInsets(
+ typeMask = WindowInsetsCompat.Type.navigationBars(),
+ ) { insets ->
+ bottomMargin = insets.bottom
+ }
+
+ // Setup clickable sign up link
+ viewBinding.tvSignUpLink.text =
+ Html.fromHtml(getString(R.string.sign_up_storacha), Html.FROM_HTML_MODE_LEGACY)
+ viewBinding.tvSignUpLink.movementMethod = LinkMovementMethod.getInstance()
+
+ // Initially disable login button
+ viewBinding.btLogin.isEnabled = false
+
+ // Add TextWatcher to email field
+ viewBinding.tvEmail.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ // Clear error when user starts typing
+ viewBinding.groupNameTextfieldContainer.error = null
+
+ // Update button state considering both email validity and loading state
+ updateLoginButtonState()
+ }
+
+ override fun afterTextChanged(s: Editable?) {}
+ })
+
+ // Setup login button click
+ viewBinding.btLogin.setOnClickListener { performLogin() }
+
+ viewBinding.tvCreateOne.setOnClickListener {
+ launcher.launch(
+ Intent(
+ Intent.ACTION_VIEW,
+ "https://console.storacha.network".toUri(),
+ ),
+ )
+ }
+
+ // Setup Enter key to trigger login and dismiss keyboard
+ viewBinding.tvEmail.setOnEditorActionListener { _, actionId, _ ->
+ when (actionId) {
+ EditorInfo.IME_ACTION_GO -> {
+ hideKeyboard()
+ performLogin()
+ true
+ }
+
+ else -> false
+ }
+ }
+
+ // Handle hardware Enter key
+ viewBinding.tvEmail.setOnKeyListener { _, keyCode, event ->
+ if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN) {
+ hideKeyboard()
+ performLogin()
+ true
+ } else {
+ false
+ }
+ }
+
+ viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
+ updateLoginButtonState()
+ viewBinding.loadingContainer.toggle(isLoading)
+ }
+
+ viewModel.loginResult.observe(
+ viewLifecycleOwner,
+ Observer { result ->
+ result.onSuccess { loginResponse ->
+ val email =
+ viewBinding.tvEmail.text
+ .toString()
+ .trim()
+ val accountManager = StorachaAccountManager(requireContext())
+ val didManager = DidManager(requireContext())
+ val userDid = didManager.getOrCreateDid()
+
+ accountManager.addAccount(
+ email = email,
+ sessionId = loginResponse.sessionId,
+ isVerified = loginResponse.verified,
+ did = userDid,
+ )
+
+ val action =
+ if (loginResponse.verified) {
+ StorachaLoginFragmentDirections.actionFragmentStorachaLoginToFragmentStorachaSpaceSetupSuccess()
+ } else {
+ StorachaLoginFragmentDirections.actionFragmentStorachaLoginToFragmentStorachaEmailVerificationSent(email)
+ }
+ findNavController().navigate(action)
+ }
+ result.onFailure {
+ Toast
+ .makeText(
+ requireContext(),
+ "Login failed: ${it.message}",
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ },
+ )
+ }
+
+ private fun performLogin() {
+ // Prevent duplicate calls while loading
+ if (viewModel.isLoading.value == true) {
+ return
+ }
+
+ val email =
+ viewBinding.tvEmail.text
+ .toString()
+ .trim()
+
+ if (!isValidEmail(email)) {
+ viewBinding.groupNameTextfieldContainer.error = "Invalid email"
+ return
+ }
+
+ viewBinding.groupNameTextfieldContainer.error = null
+
+ try {
+ val didManager = DidManager(requireContext())
+ val did = didManager.getOrCreateDid()
+ viewModel.login(email, did)
+ } catch (e: Exception) {
+ Toast
+ .makeText(
+ requireContext(),
+ "Failed to generate DID: ${e.message}",
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ }
+
+ private fun updateLoginButtonState() {
+ val email = viewBinding.tvEmail.text.toString().trim()
+ val isLoading = viewModel.isLoading.value == true
+ viewBinding.btLogin.isEnabled = !isLoading && isValidEmail(email)
+ }
+
+ private fun isValidEmail(email: String): Boolean =
+ android.util.Patterns.EMAIL_ADDRESS
+ .matcher(email)
+ .matches()
+
+ private fun hideKeyboard() {
+ val imm =
+ requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.hideSoftInputFromWindow(viewBinding.tvEmail.windowToken, 0)
+ }
+
+ override fun getToolbarTitle() = getString(R.string.label_login)
+
+ override fun shouldShowBackButton() = true
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaMediaFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaMediaFragment.kt
new file mode 100644
index 000000000..f7561246c
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaMediaFragment.kt
@@ -0,0 +1,828 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import androidx.core.net.toUri
+import androidx.core.view.MenuProvider
+import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.Lifecycle
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import androidx.recyclerview.widget.GridLayoutManager
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.FragmentStorachaMediaBinding
+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.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.services.storacha.model.UploadEntry
+import net.opendasharchive.openarchive.services.storacha.util.CarFileCreator
+import net.opendasharchive.openarchive.services.storacha.util.CarFileResult
+import net.opendasharchive.openarchive.services.storacha.util.DidManager
+import net.opendasharchive.openarchive.services.storacha.viewModel.LoadingState
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaMediaViewModel
+import net.opendasharchive.openarchive.util.Utility
+import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets
+import net.opendasharchive.openarchive.util.extensions.toggle
+import okhttp3.OkHttpClient
+import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import timber.log.Timber
+import java.io.File
+
+class StorachaMediaFragment :
+ BaseFragment(),
+ MenuProvider {
+ private lateinit var mBinding: FragmentStorachaMediaBinding
+ private val viewModel: StorachaMediaViewModel by viewModel()
+ private val args: StorachaMediaFragmentArgs by navArgs()
+ private val okHttpClient: OkHttpClient by inject()
+ private val appConfig: AppConfig by inject()
+ private lateinit var mediaAdapter: StorachaMediaGridAdapter
+ private lateinit var uploadOverlay: View
+ private var currentPhotoUri: Uri? = null
+
+ private val cameraPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
+ if (isGranted) {
+ launchCamera()
+ }
+ }
+
+ private val getMultipleContentsLauncher =
+ registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris: List ->
+ handleSelectedFiles(uris)
+ }
+
+ // 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")
+ }
+ }
+
+ // Store the last failed upload details for retry
+ private var lastFailedUpload: FailedUploadData? = null
+
+ // Track if we're doing a pull-to-refresh to avoid dual loading indicators
+ private var isPullToRefresh = false
+
+ private data class FailedUploadData(
+ val uri: Uri,
+ val tempFile: File,
+ val carFile: File,
+ val userDid: String,
+ val spaceDid: String,
+ val sessionId: String,
+ val isAdmin: Boolean,
+ )
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ mBinding = FragmentStorachaMediaBinding.inflate(layoutInflater)
+
+ mBinding.addButton.applyEdgeToEdgeInsets(
+ typeMask = WindowInsetsCompat.Type.navigationBars(),
+ ) { insets ->
+ bottomMargin = insets.bottom
+ }
+
+ mBinding.rvMediaList.layoutManager = GridLayoutManager(requireContext(), 3)
+
+ // Setup swipe refresh
+ mBinding.swipeRefreshLayout.setOnRefreshListener {
+ isPullToRefresh = true
+ refreshMedia()
+ }
+
+ // Initialize adapter once
+ mediaAdapter =
+ StorachaMediaGridAdapter(okHttpClient) { file ->
+ Timber.d("Selected: ${file.cid}")
+ openFileInBrowser(file)
+ }
+ mBinding.rvMediaList.adapter = mediaAdapter
+
+ return mBinding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ uploadOverlay = view.findViewById(R.id.upload_overlay)
+ mBinding.progressBar.toggle(true)
+
+ mBinding.addButton.setOnClickListener {
+ showContentPicker()
+ }
+
+ val spaceDid = args.spaceId
+ val sessionId = args.sessionId.ifEmpty { null }
+ val userDid = DidManager(requireContext()).getOrCreateDid()
+ viewModel.reset()
+
+ // Set callback to check if more loading is needed after each load completes
+ viewModel.onLoadComplete = {
+ (mBinding.rvMediaList.layoutManager as? GridLayoutManager)?.let { layoutManager ->
+ val lastVisible = layoutManager.findLastVisibleItemPosition()
+ val total = layoutManager.itemCount
+ if (lastVisible >= total - 3 && total > 0) {
+ viewModel.loadMoreMediaEntries(userDid, spaceDid, sessionId)
+ }
+ }
+ }
+
+ viewModel.loadMoreMediaEntries(userDid, spaceDid, sessionId)
+
+ viewModel.loading.observe(viewLifecycleOwner) { isLoading ->
+ // Only show center loading if it's not a pull-to-refresh
+ mBinding.loadingContainer.toggle(isLoading && !isPullToRefresh)
+
+ // Hide swipe refresh when loading is complete
+ if (!isLoading) {
+ mBinding.swipeRefreshLayout.isRefreshing = false
+ isPullToRefresh = false // Reset the flag
+ }
+ }
+
+ // Handle back press during upload - initialize callback
+ val backPressCallback =
+ object : OnBackPressedCallback(false) {
+ override fun handleOnBackPressed() {
+ // Do nothing - block back press during upload
+ Timber.d("Back press blocked during upload")
+ }
+ }
+ requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressCallback)
+
+ viewModel.loadingState.observe(viewLifecycleOwner) { loadingState ->
+ when (loadingState) {
+ LoadingState.LOADING_FILES -> {
+ mBinding.loadingText.text = getString(R.string.loading_files)
+ uploadOverlay.toggle(false)
+ backPressCallback.isEnabled = false
+ activity?.invalidateOptionsMenu()
+ }
+
+ LoadingState.LOADING_MORE -> {
+ mBinding.loadingText.text = getString(R.string.loading_more_files)
+ uploadOverlay.toggle(false)
+ backPressCallback.isEnabled = false
+ activity?.invalidateOptionsMenu()
+ }
+
+ LoadingState.UPLOADING -> {
+ mBinding.loadingText.text = getString(R.string.uploading_files)
+ uploadOverlay.toggle(true)
+ backPressCallback.isEnabled = true
+ activity?.invalidateOptionsMenu()
+ }
+
+ LoadingState.NONE -> {
+ // Loading container will be hidden by the loading observer
+ uploadOverlay.toggle(false)
+ backPressCallback.isEnabled = false
+ activity?.invalidateOptionsMenu()
+ }
+ }
+ }
+
+ viewModel.media.observe(viewLifecycleOwner) { mediaList ->
+ mediaAdapter.updateFiles(mediaList)
+ }
+
+ viewModel.isEmpty.observe(viewLifecycleOwner) { isEmpty ->
+ mBinding.projectsEmpty.toggle(isEmpty)
+ }
+
+ viewModel.uploadResult.observe(viewLifecycleOwner) { result ->
+ result?.fold(
+ onSuccess = { uploadResponse ->
+ Timber.d("Upload successful: CID=${uploadResponse.cid}, Size=${uploadResponse.size}")
+ // Clean up temporary files after successful upload
+ lastFailedUpload?.let { failedUpload ->
+ try {
+ failedUpload.tempFile.delete()
+ failedUpload.carFile.delete()
+ Timber.d("Cleaned up temporary files after successful upload")
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to delete temporary files")
+ }
+ }
+ lastFailedUpload = null // Clear any previous failed upload
+ showUploadSuccessDialog(uploadResponse.cid, uploadResponse.size)
+ // Clear the result immediately after showing the dialog to prevent re-showing
+ viewModel.clearUploadResult()
+ },
+ onFailure = { error ->
+ Timber.e(error, "Upload failed")
+
+ // Don't show generic error dialog if we're showing session expired dialog
+ // This is handled by separate observer
+ val isAuthError = viewModel.sessionExpired.value == true
+
+ if (!isAuthError) {
+ showUploadErrorDialog(error)
+ }
+
+ // Clear the result immediately after showing the dialog to prevent re-showing
+ viewModel.clearUploadResult()
+ },
+ )
+ }
+
+ // Observe session expiration
+ viewModel.sessionExpired.observe(viewLifecycleOwner) { expired ->
+ if (expired) {
+ showSessionExpiredDialog()
+ }
+ }
+
+ mBinding.rvMediaList.addOnScrollListener(
+ object : androidx.recyclerview.widget.RecyclerView.OnScrollListener() {
+ override fun onScrolled(
+ recyclerView: androidx.recyclerview.widget.RecyclerView,
+ dx: Int,
+ dy: Int,
+ ) {
+ if (dy > 0 && viewModel.loading.value != true) {
+ val layoutManager = recyclerView.layoutManager as GridLayoutManager
+ val lastVisible = layoutManager.findLastVisibleItemPosition()
+ val total = layoutManager.itemCount
+ if (lastVisible >= total - 3 && total > 0) {
+ viewModel.loadMoreMediaEntries(userDid, spaceDid, sessionId)
+ }
+ }
+ }
+ },
+ )
+
+ activity?.addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // Clear upload result when returning from another screen to prevent showing stale success dialog
+ viewModel.clearUploadResult()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ // Clean up any leftover temporary files
+ lastFailedUpload?.let { failedUpload ->
+ try {
+ if (failedUpload.tempFile.exists()) {
+ failedUpload.tempFile.delete()
+ Timber.d("Cleaned up temp file: ${failedUpload.tempFile.name}")
+ }
+ if (failedUpload.carFile.exists()) {
+ failedUpload.carFile.delete()
+ Timber.d("Cleaned up CAR file: ${failedUpload.carFile.name}")
+ }
+ } catch (e: Exception) {
+ Timber.e("Failed to clean up temp files: ${e.message}")
+ }
+ }
+ }
+
+ override fun onCreateMenu(
+ menu: Menu,
+ menuInflater: MenuInflater,
+ ) {
+ menuInflater.inflate(R.menu.menu_browse_folder, menu)
+ }
+
+ override fun onPrepareMenu(menu: Menu) {
+ super.onPrepareMenu(menu)
+ val addMenuItem = menu.findItem(R.id.action_add)
+ if (args.isAdmin) {
+ addMenuItem?.isVisible = true
+ addMenuItem?.title = getString(R.string.manage_access)
+ addMenuItem?.isEnabled = viewModel.loadingState.value != LoadingState.UPLOADING
+ } else {
+ addMenuItem?.isVisible = false
+ }
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean =
+ when (menuItem.itemId) {
+ R.id.action_add -> {
+ val action =
+ StorachaMediaFragmentDirections.actionFragmentStorachaMediaToFragmentStorachaViewDids(
+ spaceId = args.spaceId,
+ sessionId = args.sessionId,
+ )
+ findNavController().navigate(action)
+ true
+ }
+
+ else -> false
+ }
+
+ private fun handleMedia(uri: Uri) {
+ Timber.d("Going to upload file: $uri")
+
+ // Clean up any previous failed upload before starting a new one
+ lastFailedUpload?.let { previousFailedUpload ->
+ try {
+ if (previousFailedUpload.tempFile.exists()) {
+ previousFailedUpload.tempFile.delete()
+ Timber.d("Cleaned up previous failed upload temp file: ${previousFailedUpload.tempFile.name}")
+ }
+ if (previousFailedUpload.carFile.exists()) {
+ previousFailedUpload.carFile.delete()
+ Timber.d("Cleaned up previous failed upload CAR file: ${previousFailedUpload.carFile.name}")
+ }
+ } catch (e: Exception) {
+ Timber.e("Failed to clean up previous failed upload: ${e.message}")
+ }
+ lastFailedUpload = null
+ }
+
+ val userDid = DidManager(requireContext()).getOrCreateDid()
+ val spaceDid = args.spaceId
+
+ // Check if URI is a FileProvider URI pointing to our cache directory
+ val tempFile =
+ try {
+ if (uri.scheme == "content" && uri.authority == "${requireContext().packageName}.provider") {
+ // This is likely from our camera - try to get the actual file path
+ Timber.d("Camera URI detected: $uri, path: ${uri.path}")
+ val path = uri.path?.removePrefix("/cache/")
+ if (path != null && path != uri.path) {
+ val existingFile = File(requireContext().cacheDir, path)
+ Timber.d(
+ "Checking for existing file at: ${existingFile.absolutePath}, exists: ${existingFile.exists()}, size: ${existingFile.length()}",
+ )
+ if (existingFile.exists() && existingFile.length() > 0) {
+ Timber.d("Using existing camera file: ${existingFile.absolutePath}, size: ${existingFile.length()} bytes")
+ existingFile
+ } else {
+ Timber.w("File does not exist or is empty: ${existingFile.absolutePath}")
+ null
+ }
+ } else {
+ Timber.w("Could not extract path from URI: $uri")
+ null
+ }
+ } else {
+ null
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Error checking for existing file")
+ null
+ }
+
+ val finalTempFile =
+ if (tempFile != null) {
+ // Use the existing file from camera
+ tempFile
+ } else {
+ // Need to copy from URI (gallery, etc.)
+ val title =
+ Utility.getUriDisplayName(requireContext(), uri)
+ ?: "IMG_${System.currentTimeMillis()}.jpg"
+ val newTempFile = Utility.getOutputMediaFileByCacheNoTimestamp(requireContext(), title)
+
+ if (newTempFile == null) {
+ Timber.e("Failed to create temp file for URI: $uri")
+ showError("Failed to create temporary file")
+ return
+ }
+
+ // Use the exact same pattern as Picker.kt for file copying
+ try {
+ requireContext().contentResolver.openInputStream(uri)?.use { inputStream ->
+ if (!Utility.writeStreamToFile(inputStream, newTempFile)) {
+ Timber.e("Failed to write stream to file for URI: $uri")
+ showError("Failed to copy image data")
+ return
+ }
+ } ?: run {
+ Timber.e("Failed to open input stream for URI: $uri")
+ showError("Failed to read image")
+ return
+ }
+ } catch (e: java.io.FileNotFoundException) {
+ Timber.e(e, "File not found for URI: $uri")
+ showError("File not found")
+ return
+ } catch (e: SecurityException) {
+ Timber.e(e, "Permission denied for URI: $uri")
+ showError("Permission denied to read image")
+ return
+ } catch (e: java.io.IOException) {
+ Timber.e(e, "IO error reading URI: $uri")
+ showError("Failed to read image: ${e.message}")
+ return
+ }
+
+ // Verify file was actually written with content
+ if (!newTempFile.exists() || newTempFile.length() == 0L) {
+ Timber.e("Temp file is empty after copy. File exists: ${newTempFile.exists()}, size: ${newTempFile.length()}")
+ showError("Image file is empty (0 bytes). Please try again.")
+ return
+ }
+
+ Timber.d("Successfully copied file from URI to temp file: ${newTempFile.absolutePath}, size: ${newTempFile.length()} bytes")
+ newTempFile
+ }
+
+ // Generate proper CAR file from the temporary file (writes directly to cache dir)
+ val carResult = CarFileCreator.createCarFile(finalTempFile, requireContext().cacheDir)
+
+ val uploadSessionId = args.sessionId.ifEmpty { null }
+
+ // Store upload details for potential retry and cleanup
+ lastFailedUpload =
+ FailedUploadData(
+ uri = uri,
+ tempFile = finalTempFile,
+ carFile = carResult.carFile,
+ userDid = userDid,
+ spaceDid = spaceDid,
+ sessionId = uploadSessionId ?: "",
+ isAdmin = args.isAdmin,
+ )
+
+ viewModel.uploadFile(
+ finalTempFile,
+ carResult,
+ userDid,
+ spaceDid,
+ uploadSessionId,
+ args.isAdmin,
+ )
+ }
+
+ private fun handleSelectedFiles(uris: List) {
+ if (uris.isNotEmpty()) {
+ for (uri in uris) {
+ handleMedia(uri)
+ }
+ } else {
+ Timber.d("No files selected")
+ }
+ }
+
+ private fun openFilePicker() {
+ getMultipleContentsLauncher.launch("*/*")
+ }
+
+ private fun showContentPicker() {
+ val contentPicker =
+ StorachaContentPickerFragment { contentType ->
+ when (contentType) {
+ StorachaContentType.CAMERA -> openCamera()
+ StorachaContentType.FILES -> openFilePicker()
+ }
+ }
+ contentPicker.show(parentFragmentManager, StorachaContentPickerFragment.TAG)
+ }
+
+ 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(getString(R.string.camera_permission))
+ message =
+ UiText.DynamicString(getString(R.string.camera_access_is_needed_to_take_pictures_please_grant_permission))
+ positiveButton {
+ text = UiText.DynamicString(getString(R.string.accept))
+ action = {
+ cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+ neutralButton {
+ text = UiText.DynamicString(getString(R.string.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,
+ useCleanFilenames = true, // Use IMG_123.jpg instead of 20250119_143045.IMG_123.jpg
+ )
+ Picker.launchCustomCamera(
+ requireActivity(),
+ customCameraLauncher,
+ cameraConfig,
+ )
+ } else {
+ // Use modern camera launcher (photo only) with custom filename format
+ // File will be named: IMG_1234567890.jpg (without double timestamp)
+ try {
+ val fileName = "IMG_${System.currentTimeMillis()}.jpg"
+ val file = Utility.getOutputMediaFileByCacheNoTimestamp(requireContext(), fileName)
+
+ file?.let {
+ currentPhotoUri =
+ androidx.core.content.FileProvider.getUriForFile(
+ requireContext(),
+ "${requireContext().packageName}.provider",
+ it,
+ )
+ Timber.d("Launching modern camera with URI: $currentPhotoUri, filename: $fileName")
+ modernCameraLauncher.launch(currentPhotoUri)
+ } ?: run {
+ Timber.e("Failed to create temp file for camera")
+ Toast
+ .makeText(requireContext(), "Failed to prepare camera", Toast.LENGTH_SHORT)
+ .show()
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Error setting up camera")
+ Toast.makeText(requireContext(), "Camera setup failed", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun showError(message: String) {
+ Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show()
+ }
+
+ private fun openFileInBrowser(file: UploadEntry) {
+ try {
+ val intent = Intent(Intent.ACTION_VIEW, file.gatewayUrl.toUri())
+ startActivity(intent)
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to open file in browser: ${file.cid}")
+ }
+ }
+
+ override fun getToolbarTitle(): String = arguments?.getString("space_name") ?: getString(R.string.browse_files)
+
+ private fun showUploadSuccessDialog(
+ cid: String,
+ size: Long,
+ ) {
+ dialogManager.showDialog(dialogManager.requireResourceProvider()) {
+ type = DialogType.Success
+ title = UiText.DynamicString("Success!")
+ message =
+ UiText.DynamicString(
+ "File uploaded successfully!\nCID:\n$cid\nSize: ${
+ formatFileSize(size)
+ }",
+ )
+ positiveButton {
+ text = UiText.DynamicString("Got it")
+ action = { }
+ }
+ }
+ }
+
+ private fun showUploadErrorDialog(error: Throwable) {
+ val fullErrorMessage = error.message ?: "Unknown error"
+ val userFriendlyMessage = parseErrorMessage(fullErrorMessage)
+
+ dialogManager.showDialog(dialogManager.requireResourceProvider()) {
+ type = DialogType.Error
+ title = UiText.DynamicString("Upload Failed")
+ message = UiText.DynamicString(userFriendlyMessage)
+ positiveButton {
+ text = UiText.DynamicString("Try Again")
+ action = { retryLastUpload() }
+ }
+ neutralButton {
+ text = UiText.DynamicString("Cancel")
+ action = {
+ lastFailedUpload = null // Clear failed upload data
+ }
+ }
+ }
+ }
+
+ private fun parseErrorMessage(fullMessage: String): String =
+ when {
+ // Session/Authentication issues are now handled by AuthInterceptor + ViewModel observers
+ // (showEmailVerificationRequiredDialog / showSessionExpiredDialog)
+ // This old code path is removed to avoid duplicate error messages
+
+ // Permission issues - Clear and actionable
+ fullMessage.contains("Claim not authorized", ignoreCase = true) ||
+ fullMessage.contains(
+ "Access forbidden",
+ ignoreCase = true,
+ ) || fullMessage.contains("403") -> {
+ getString(R.string.you_don_t_have_permission_to_upload_to_this_space_please_check_with_the_space_owner)
+ }
+
+ // Network connectivity issues
+ fullMessage.contains("network", ignoreCase = true) ||
+ fullMessage.contains(
+ "connection",
+ ignoreCase = true,
+ ) || fullMessage.contains("ConnectException", ignoreCase = true) -> {
+ getString(R.string.can_t_connect_to_the_server_please_check_your_internet_connection_and_try_again)
+ }
+
+ // Service unavailable
+ fullMessage.contains(
+ "Cannot POST",
+ ignoreCase = true,
+ ) || fullMessage.contains("503") ||
+ fullMessage.contains(
+ "service unavailable",
+ ignoreCase = true,
+ )
+ -> {
+ getString(R.string.the_storage_service_is_temporarily_unavailable_please_try_again_in_a_few_minutes)
+ }
+
+ // File too large or timeout
+ fullMessage.contains("timeout", ignoreCase = true) ||
+ fullMessage.contains(
+ "too large",
+ ignoreCase = true,
+ ) || fullMessage.contains("431") -> {
+ getString(
+ R.string.the_file_may_be_too_large_or_the_upload_is_taking_too_long_try_with_a_smaller_file_or_check_your_connection,
+ )
+ }
+
+ // Server overloaded
+ fullMessage.contains("429") ||
+ fullMessage.contains(
+ "too many requests",
+ ignoreCase = true,
+ )
+ -> {
+ getString(R.string.the_server_is_busy_please_wait_a_moment_and_try_again)
+ }
+
+ // Generic server errors
+ fullMessage.contains("500") ||
+ fullMessage.contains(
+ "server error",
+ ignoreCase = true,
+ )
+ -> {
+ getString(R.string.something_went_wrong_on_the_server_please_try_again)
+ }
+
+ // Token/authorization issues (usually auto-retried)
+ fullMessage.contains(
+ "InvalidToken",
+ ignoreCase = true,
+ ) ||
+ fullMessage.contains(
+ "expired",
+ ignoreCase = true,
+ ) || fullMessage.contains("delegation", ignoreCase = true) -> {
+ getString(R.string.there_was_an_authentication_issue_the_app_will_try_again_automatically)
+ }
+
+ // Storage/Bridge specific issues
+ fullMessage.contains(
+ "Bridge",
+ ignoreCase = true,
+ ) ||
+ fullMessage.contains(
+ "S3 upload failed",
+ ignoreCase = true,
+ ) ||
+ fullMessage.contains(
+ "store/add",
+ ignoreCase = true,
+ ) || fullMessage.contains("upload/add", ignoreCase = true) -> {
+ getString(R.string.there_was_a_problem_with_the_storage_service_please_try_again)
+ }
+
+ // Space/storage issues
+ fullMessage.contains("space", ignoreCase = true) ||
+ fullMessage.contains(
+ "storage",
+ ignoreCase = true,
+ )
+ -> {
+ getString(R.string.there_might_not_be_enough_storage_space_available_please_try_again_or_contact_support)
+ }
+
+ // Generic fallback - keep it simple
+ else -> {
+ getString(R.string.something_went_wrong_with_the_upload_please_try_again)
+ }
+ }
+
+ private fun retryLastUpload() {
+ lastFailedUpload?.let { failedUpload ->
+ Timber.d("Retrying upload for file: ${failedUpload.uri}")
+
+ // Clean up old CAR file if it exists
+ try {
+ if (failedUpload.carFile.exists()) {
+ failedUpload.carFile.delete()
+ Timber.d("Deleted old CAR file before retry: ${failedUpload.carFile.name}")
+ }
+ } catch (e: Exception) {
+ Timber.e("Failed to delete old CAR file: ${e.message}")
+ }
+
+ // Regenerate CAR file for retry
+ val carResult =
+ CarFileCreator.createCarFile(failedUpload.tempFile, requireContext().cacheDir)
+ val retrySessionId = failedUpload.sessionId.ifEmpty { null }
+
+ // Update lastFailedUpload with new CAR file
+ lastFailedUpload = failedUpload.copy(carFile = carResult.carFile)
+
+ viewModel.uploadFile(
+ failedUpload.tempFile,
+ carResult,
+ failedUpload.userDid,
+ failedUpload.spaceDid,
+ retrySessionId,
+ failedUpload.isAdmin,
+ )
+ }
+ }
+
+ private fun refreshMedia() {
+ val spaceDid = args.spaceId
+ val sessionId = args.sessionId.ifEmpty { null }
+ val userDid = DidManager(requireContext()).getOrCreateDid()
+
+ viewModel.refreshFromStart()
+ viewModel.loadMoreMediaEntries(userDid, spaceDid, sessionId)
+ }
+
+ private fun formatFileSize(bytes: Long): String =
+ when {
+ bytes >= 1024 * 1024 -> String.format("%.1f MB", bytes / 1024.0 / 1024.0)
+ bytes >= 1024 -> String.format("%.1f KB", bytes / 1024.0)
+ else -> "$bytes B"
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaMediaGridAdapter.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaMediaGridAdapter.kt
new file mode 100644
index 000000000..8a8d58439
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaMediaGridAdapter.kt
@@ -0,0 +1,156 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.content.Intent
+import android.content.res.ColorStateList
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import androidx.core.content.ContextCompat
+import androidx.core.net.toUri
+import androidx.recyclerview.widget.RecyclerView
+import com.squareup.picasso.Callback
+import com.squareup.picasso.Picasso
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.StorachaMediaGridItemBinding
+import net.opendasharchive.openarchive.services.storacha.model.UploadEntry
+import net.opendasharchive.openarchive.services.storacha.util.FileMetadata
+import net.opendasharchive.openarchive.services.storacha.util.FileMetadataFetcher
+import net.opendasharchive.openarchive.services.storacha.util.FileType
+import okhttp3.OkHttpClient
+
+class StorachaMediaGridAdapter(
+ client: OkHttpClient,
+ private val onClick: (file: UploadEntry) -> Unit,
+) : RecyclerView.Adapter() {
+ private var files: List = emptyList()
+ private val metadataFetcher = FileMetadataFetcher(client)
+ private val metadataCache = mutableMapOf()
+
+ fun updateFiles(newFiles: List) {
+ files = newFiles
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaGridViewHolder {
+ val binding = StorachaMediaGridItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return MediaGridViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: MediaGridViewHolder, position: Int) {
+ holder.bind(files[position])
+ }
+
+ override fun getItemCount(): Int = files.size
+
+ inner class MediaGridViewHolder(
+ private val binding: StorachaMediaGridItemBinding,
+ ) : RecyclerView.ViewHolder(binding.root) {
+ private var currentJob: Job? = null
+ private var currentCid: String? = null
+ private val context get() = binding.root.context
+
+ fun bind(file: UploadEntry) {
+ currentJob?.cancel()
+ Picasso.get().cancelRequest(binding.icon)
+ currentCid = file.cid
+
+ binding.loadingSpinner.visibility = View.VISIBLE
+ binding.icon.visibility = View.INVISIBLE
+ binding.name.text = "${file.cid.take(12)}..."
+ binding.didKey.text = file.cid
+ binding.root.setOnClickListener { onClick(file) }
+
+ metadataCache[file.cid]?.let { metadata ->
+ showIcon()
+ updateWithMetadata(metadata, file)
+ } ?: fetchMetadata(file)
+ }
+
+ private fun fetchMetadata(file: UploadEntry) {
+ val expectedCid = file.cid
+ currentJob = CoroutineScope(Dispatchers.Main).launch {
+ val metadata = withContext(Dispatchers.IO) {
+ metadataFetcher.fetchFileMetadata(file.gatewayUrl.replace(".w3s.",".dweb."))
+ }
+
+ if (currentCid == expectedCid) {
+ metadataCache[file.cid] = metadata
+ if (metadata != null) {
+ updateWithMetadata(metadata, file)
+ } else {
+ showIcon()
+ setIcon(FileType.UNKNOWN)
+ }
+ }
+ }
+ }
+
+ private fun showIcon() {
+ binding.loadingSpinner.visibility = View.GONE
+ binding.icon.visibility = View.VISIBLE
+ }
+
+ private fun updateWithMetadata(metadata: FileMetadata, file: UploadEntry) {
+ binding.name.text = metadata.fileName
+
+ if (metadata.fileType == FileType.IMAGE) {
+ loadImageThumbnail(metadata.directUrl, file.cid)
+ } else {
+ showIcon()
+ setIcon(metadata.fileType)
+ }
+
+ binding.root.setOnClickListener {
+ runCatching {
+ context.startActivity(Intent(Intent.ACTION_VIEW, metadata.directUrl.toUri()))
+ }.onFailure {
+ onClick(file)
+ }
+ }
+ }
+
+ private fun setIcon(fileType: FileType) {
+ binding.icon.apply {
+ scaleType = ImageView.ScaleType.CENTER_INSIDE
+ setImageDrawable(ContextCompat.getDrawable(context, fileType.iconRes)?.mutate())
+ imageTintList = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.colorOnBackground))
+ visibility = View.VISIBLE
+ }
+ binding.loadingSpinner.visibility = View.GONE
+ }
+
+ private fun loadImageThumbnail(imageUrl: String, expectedCid: String) {
+ binding.icon.apply {
+ imageTintList = null
+ clearColorFilter()
+ scaleType = ImageView.ScaleType.CENTER_CROP
+ }
+
+ Picasso.get()
+ .load(imageUrl)
+ .resize(240, 240)
+ .centerCrop()
+ .into(binding.icon, object : Callback {
+ override fun onSuccess() {
+ if (currentCid == expectedCid) {
+ showIcon()
+ binding.icon.imageTintList = null
+ }
+ }
+
+ override fun onError(e: Exception?) {
+ if (currentCid == expectedCid) {
+ showIcon()
+ setIcon(FileType.IMAGE)
+ }
+ }
+ })
+ }
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaSpaceSetupSuccessFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaSpaceSetupSuccessFragment.kt
new file mode 100644
index 000000000..e0d45cc2b
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaSpaceSetupSuccessFragment.kt
@@ -0,0 +1,43 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.WindowInsetsCompat
+import androidx.navigation.fragment.findNavController
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.FragmentStorachaSpaceSetupSuccessBinding
+import net.opendasharchive.openarchive.features.core.BaseFragment
+import net.opendasharchive.openarchive.util.extensions.applyEdgeToEdgeInsets
+
+class StorachaSpaceSetupSuccessFragment : BaseFragment() {
+ private lateinit var mBinding: FragmentStorachaSpaceSetupSuccessBinding
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ mBinding = FragmentStorachaSpaceSetupSuccessBinding.inflate(inflater)
+
+ mBinding.btAuthenticate.applyEdgeToEdgeInsets(
+ typeMask = WindowInsetsCompat.Type.navigationBars(),
+ ) { insets ->
+ bottomMargin = insets.bottom
+ }
+
+ mBinding.btAuthenticate.setOnClickListener { _ ->
+ val action =
+ StorachaSpaceSetupSuccessFragmentDirections
+ .actionFragmentStorachaSpaceSetupSuccessToFragmentStorachaBrowseSpaces()
+ findNavController().navigate(action)
+ }
+
+ return mBinding.root
+ }
+
+ override fun getToolbarTitle() = getString(R.string.space_setup_success_title)
+
+ override fun shouldShowBackButton() = false
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaViewAccountsFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaViewAccountsFragment.kt
new file mode 100644
index 000000000..b65a18c24
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaViewAccountsFragment.kt
@@ -0,0 +1,165 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.MenuProvider
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.LinearLayoutManager
+import kotlinx.coroutines.launch
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.FragmentStorachaAccountsBinding
+import net.opendasharchive.openarchive.features.core.BaseFragment
+import net.opendasharchive.openarchive.services.storacha.util.SessionManager
+import net.opendasharchive.openarchive.services.storacha.util.StorachaAccountManager
+import net.opendasharchive.openarchive.util.extensions.toggle
+import org.koin.android.ext.android.inject
+
+class StorachaViewAccountsFragment : BaseFragment() {
+// , MenuProvider
+
+ private lateinit var mBinding: FragmentStorachaAccountsBinding
+ private val sessionManager: SessionManager by inject()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ mBinding = FragmentStorachaAccountsBinding.inflate(layoutInflater)
+ mBinding.rvAccountList.layoutManager = LinearLayoutManager(requireContext())
+ return mBinding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ ViewCompat.setOnApplyWindowInsetsListener(mBinding.rvAccountList) { view, windowInsets ->
+ val insets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars())
+
+ view.updatePadding(
+ bottom = insets.bottom + view.paddingBottom
+ )
+
+ windowInsets
+ }
+
+ // Validate session before loading accounts
+ validateSessionAndLoadAccounts()
+// activity?.addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
+ }
+
+ /**
+ * Validates the current session before loading accounts.
+ * If session is invalid, removes the invalid account and navigates to login screen.
+ */
+ private fun validateSessionAndLoadAccounts() {
+ mBinding.loadingContainer.toggle(true)
+
+ lifecycleScope.launch {
+ val isValid = sessionManager.validateSession()
+
+ if (!isValid) {
+ // Session is invalid, remove the invalid account and navigate to login
+ sessionManager.removeCurrentAccount()
+ mBinding.loadingContainer.toggle(false)
+
+ // Navigate to login and clear back stack to storacha fragment
+ // This prevents infinite loop when pressing back
+ findNavController().navigate(
+ R.id.fragment_storacha_login,
+ null,
+ androidx.navigation.navOptions {
+ popUpTo(R.id.fragment_storacha) {
+ inclusive = false
+ }
+ }
+ )
+ } else {
+ // Session is valid, proceed to load accounts
+ loadLoggedInAccounts()
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // Refresh accounts when returning from account details (in case account was logged out)
+ // Also re-validate session
+ validateSessionAndLoadAccounts()
+ }
+
+ private fun loadLoggedInAccounts() {
+ // Check if fragment is still attached before starting
+ if (!isAdded || context == null) {
+ return
+ }
+
+ mBinding.loadingContainer.toggle(true)
+
+ Handler(Looper.getMainLooper()).postDelayed({
+ // Double-check fragment is still attached before accessing context
+ if (!isAdded || context == null) {
+ return@postDelayed
+ }
+
+ val accountManager = StorachaAccountManager(requireContext())
+ val loggedInAccounts = accountManager.getLoggedInAccounts()
+
+ if (loggedInAccounts.isEmpty()) {
+ mBinding.projectsEmpty.toggle(true)
+ mBinding.rvAccountList.adapter = null
+ } else {
+ mBinding.projectsEmpty.toggle(false)
+ mBinding.rvAccountList.adapter =
+ StorachaBrowseAccountsAdapter(
+ loggedInAccounts.map { Account(it.email, it.sessionId) },
+ false,
+ ) { account ->
+ val action =
+ StorachaViewAccountsFragmentDirections.fragmentStorachaAccountsToFragmentStorachaAccountDetails(
+ email = account.email,
+ sessionId = account.sessionId,
+ )
+ findNavController().navigate(action)
+ }
+ }
+ mBinding.loadingContainer.toggle(false)
+ }, 500)
+ }
+
+ override fun getToolbarTitle(): String = getString(R.string.accounts)
+
+// override fun onCreateMenu(
+// menu: Menu,
+// menuInflater: MenuInflater,
+// ) {
+// menuInflater.inflate(R.menu.menu_browse_folder, menu)
+// }
+//
+// override fun onMenuItemSelected(menuItem: MenuItem): Boolean =
+// when (menuItem.itemId) {
+// R.id.action_add -> {
+// val action =
+// StorachaViewAccountsFragmentDirections.actionFragmentStorachaAccountsToFragmentStorachaLogin()
+// findNavController().navigate(action)
+// true
+// }
+//
+// else -> false
+// }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaViewDIDsFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaViewDIDsFragment.kt
new file mode 100644
index 000000000..4422eae53
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/StorachaViewDIDsFragment.kt
@@ -0,0 +1,159 @@
+package net.opendasharchive.openarchive.services.storacha
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.MenuProvider
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.lifecycle.Lifecycle
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import androidx.recyclerview.widget.LinearLayoutManager
+import net.opendasharchive.openarchive.R
+import net.opendasharchive.openarchive.databinding.FragmentStorachaViewDidsBinding
+import net.opendasharchive.openarchive.features.core.BaseFragment
+import net.opendasharchive.openarchive.features.core.UiImage
+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.services.storacha.viewModel.DidAccount
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaViewDIDsViewModel
+import net.opendasharchive.openarchive.util.extensions.toggle
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class StorachaViewDIDsFragment :
+ BaseFragment(),
+ MenuProvider {
+ private lateinit var mBinding: FragmentStorachaViewDidsBinding
+ private val viewModel: StorachaViewDIDsViewModel by viewModel()
+ private val args: StorachaViewDIDsFragmentArgs by navArgs()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ mBinding = FragmentStorachaViewDidsBinding.inflate(layoutInflater)
+ mBinding.rvFolderList.layoutManager = LinearLayoutManager(requireContext())
+ return mBinding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ ViewCompat.setOnApplyWindowInsetsListener(mBinding.rvFolderList) { view, windowInsets ->
+ val insets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars())
+
+ view.updatePadding(
+ bottom = insets.bottom + view.paddingBottom
+ )
+
+ windowInsets
+ }
+
+ setupObservers()
+ viewModel.loadDIDs(args.sessionId, args.spaceId)
+ activity?.addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
+ }
+
+ private fun setupObservers() {
+ viewModel.loading.observe(viewLifecycleOwner) { isLoading ->
+ mBinding.progressBar.toggle(isLoading)
+ }
+
+ viewModel.dids.observe(viewLifecycleOwner) { dids ->
+ mBinding.projectsEmpty.toggle(dids.isEmpty())
+ // Convert DidAccount to Account for the adapter
+ val accounts =
+ dids.map { Account(it.did, "") } // Empty sessionId since DIDs don't need it
+ mBinding.rvFolderList.adapter =
+ StorachaBrowseAccountsAdapter(
+ accounts,
+ true,
+ ) { account ->
+ // Convert back to DidAccount for the dialog
+ val didAccount =
+ dids.find { it.did == account.email }
+ ?: return@StorachaBrowseAccountsAdapter
+ showRevokeDialog(didAccount)
+ }
+ }
+
+ viewModel.error.observe(viewLifecycleOwner) { errorMessage ->
+ errorMessage?.let {
+ // TODO: Show error dialog or snackbar
+ // For now, you can add proper error handling here
+ }
+ }
+
+ // Observe session expiration
+ viewModel.sessionExpired.observe(viewLifecycleOwner) { expired ->
+ if (expired) {
+ showSessionExpiredDialog()
+ }
+ }
+ }
+
+ private fun showRevokeDialog(account: DidAccount) {
+ dialogManager.showDialog(dialogManager.requireResourceProvider()) {
+ type = DialogType.Error
+ icon = UiImage.DrawableResource(R.drawable.ic_trash)
+ title = UiText.StringResource(R.string.revoke_access)
+ message = UiText.StringResource(R.string.revoke_access_prompt)
+ destructiveButton {
+ text = UiText.StringResource(R.string.revoke)
+ action = {
+ viewModel.revokeDID(args.sessionId, args.spaceId, account)
+ dialogManager.dismissDialog()
+ }
+ }
+ neutralButton {
+ text = UiText.StringResource(R.string.lbl_Cancel)
+ action = {
+ dialogManager.dismissDialog()
+ }
+ }
+ }
+ }
+
+ override fun getToolbarTitle(): String = getString(R.string.manage_access)
+
+ override fun onCreateMenu(
+ menu: Menu,
+ menuInflater: MenuInflater,
+ ) {
+ menuInflater.inflate(R.menu.menu_browse_folder, menu)
+ }
+
+ override fun onPrepareMenu(menu: Menu) {
+ super.onPrepareMenu(menu)
+ val addMenuItem = menu.findItem(R.id.action_add)
+ addMenuItem?.isVisible = true
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean =
+ when (menuItem.itemId) {
+ R.id.action_add -> {
+ val existingDids = viewModel.dids.value?.map { it.did }?.toTypedArray() ?: emptyArray()
+ val action =
+ StorachaViewDIDsFragmentDirections.actionFragmentStorachaViewDidsToFragmentStorachaDidAccess(
+ args.spaceId,
+ args.sessionId,
+ existingDids,
+ )
+ findNavController().navigate(action)
+ true
+ }
+
+ else -> false
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/di/StorachaModule.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/di/StorachaModule.kt
new file mode 100644
index 000000000..ab7901613
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/di/StorachaModule.kt
@@ -0,0 +1,106 @@
+package net.opendasharchive.openarchive.services.storacha.di
+
+import com.google.gson.GsonBuilder
+import net.opendasharchive.openarchive.services.storacha.network.AuthInterceptor
+import net.opendasharchive.openarchive.services.storacha.service.StorachaApiService
+import net.opendasharchive.openarchive.services.storacha.util.BridgeUploader
+import net.opendasharchive.openarchive.services.storacha.util.SecureStorage
+import net.opendasharchive.openarchive.services.storacha.util.SessionManager
+import net.opendasharchive.openarchive.services.storacha.util.StorachaAccountManager
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaAccountDetailsViewModel
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaBrowseSpacesViewModel
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaDIDAccessViewModel
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaEmailVerificationSentViewModel
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaLoginViewModel
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaMediaViewModel
+import net.opendasharchive.openarchive.services.storacha.viewModel.StorachaViewDIDsViewModel
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import org.koin.core.module.dsl.viewModel
+import org.koin.core.module.dsl.viewModelOf
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.concurrent.TimeUnit
+
+val storachaModule =
+ module {
+
+ // Secure storage for Storacha accounts (used by AccountManager)
+ single(qualifier = named("accountsStorage")) {
+ SecureStorage(get(), "storacha_accounts")
+ }
+
+ // Secure storage for DID keys (used by SessionManager)
+ single(qualifier = named("keysStorage")) {
+ SecureStorage(get(), "storacha_did_keys")
+ }
+
+ // Account manager
+ single {
+ StorachaAccountManager(get())
+ }
+
+ // Session manager (depends on API service, account manager, and keys storage)
+ single {
+ SessionManager(get(), get(), get(named("keysStorage")))
+ }
+
+ // Logging interceptor
+ single {
+ HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+ }
+
+ // Auth interceptor (uses lazy provider to break circular dependency)
+ single {
+ AuthInterceptor { get() }
+ }
+
+ // OkHttp client with interceptors
+ single {
+ OkHttpClient
+ .Builder()
+ .addInterceptor(get())
+ .addInterceptor(get())
+ .connectTimeout(60, TimeUnit.SECONDS)
+ .readTimeout(300, TimeUnit.SECONDS) // 5 minutes for large file uploads
+ .writeTimeout(300, TimeUnit.SECONDS) // 5 minutes for large file uploads
+ .build()
+ }
+
+ single {
+ GsonBuilder().create()
+ }
+
+ single {
+ Retrofit
+ .Builder()
+ .baseUrl("http://save-storacha.staging.hypha.coop:3000/") // Change to actual API base URL
+ .client(get())
+ .addConverterFactory(GsonConverterFactory.create(get()))
+ .build()
+ }
+
+ single {
+ get().create(StorachaApiService::class.java)
+ }
+
+ single { BridgeUploader(get()) }
+
+ viewModelOf(::StorachaLoginViewModel)
+ viewModelOf(::StorachaBrowseSpacesViewModel)
+ viewModelOf(::StorachaMediaViewModel)
+ viewModelOf(::StorachaViewDIDsViewModel)
+ viewModelOf(::StorachaDIDAccessViewModel)
+ viewModelOf(::StorachaAccountDetailsViewModel)
+ viewModel { (application: android.app.Application, sessionId: String) ->
+ StorachaEmailVerificationSentViewModel(
+ application,
+ get(),
+ sessionId,
+ )
+ }
+ }
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/AccountUsageResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/AccountUsageResponse.kt
new file mode 100644
index 000000000..8c096bb61
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/AccountUsageResponse.kt
@@ -0,0 +1,7 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class AccountUsageResponse(
+ val totalUsage: Usage,
+ val spaces: List,
+ val planProduct: String? = null, // e.g., "did:web:starter.web3.storage"
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/BridgeApiModels.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/BridgeApiModels.kt
new file mode 100644
index 000000000..197130a4e
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/BridgeApiModels.kt
@@ -0,0 +1,54 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+// Bridge API task request model
+data class BridgeTaskRequest(
+ val tasks: List>,
+)
+
+// Store/Add task models
+data class StoreAddTask(
+ val link: Map,
+ val size: Long,
+)
+
+data class StoreAddResponse(
+ val p: StoreAddResult,
+)
+
+data class StoreAddResult(
+ val out: StoreAddOutcome,
+)
+
+data class StoreAddOutcome(
+ val ok: StoreAddSuccess? = null,
+ val error: Map? = null,
+)
+
+data class StoreAddSuccess(
+ val status: String,
+ val url: String? = null,
+ val headers: Map? = null,
+)
+
+// Upload/Add task models
+data class UploadAddTask(
+ val root: Map,
+)
+
+data class UploadAddResponse(
+ val p: UploadAddResult,
+)
+
+data class UploadAddResult(
+ val out: UploadAddOutcome,
+)
+
+data class UploadAddOutcome(
+ val ok: UploadAddSuccess? = null,
+ val error: Map? = null,
+)
+
+data class UploadAddSuccess(
+ val root: Map,
+ val shards: List = emptyList(),
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/BridgeTokenRequest.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/BridgeTokenRequest.kt
new file mode 100644
index 000000000..01f4a8980
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/BridgeTokenRequest.kt
@@ -0,0 +1,8 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class BridgeTokenRequest(
+ val resource: String,
+ val can: List,
+ val expiration: Long,
+ val json: Boolean = false,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/BridgeTokenResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/BridgeTokenResponse.kt
new file mode 100644
index 000000000..9f3cb09ab
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/BridgeTokenResponse.kt
@@ -0,0 +1,11 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class BridgeTokenResponse(
+ val success: Boolean,
+ val tokens: BridgeTokens,
+)
+
+data class BridgeTokens(
+ val xAuthSecret: String,
+ val authorization: String,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/BridgeUploadResult.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/BridgeUploadResult.kt
new file mode 100644
index 000000000..2b4022651
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/BridgeUploadResult.kt
@@ -0,0 +1,7 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class BridgeUploadResult(
+ val rootCid: String,
+ val carCid: String,
+ val size: Long,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationCreateResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationCreateResponse.kt
new file mode 100644
index 000000000..732f40398
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationCreateResponse.kt
@@ -0,0 +1,9 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class DelegationCreateResponse(
+ val message: String,
+ val principalDid: String,
+ val delegationCid: String,
+ val expiresAt: String,
+ val createdBy: String,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationDetailsResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationDetailsResponse.kt
new file mode 100644
index 000000000..b7a3ff769
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationDetailsResponse.kt
@@ -0,0 +1,8 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class DelegationDetailsResponse(
+ val userDid: String,
+ val spaceDid: String,
+ val delegationCar: String,
+ val expiresAt: String,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationListResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationListResponse.kt
new file mode 100644
index 000000000..8e26b2b3d
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationListResponse.kt
@@ -0,0 +1,8 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class DelegationListResponse(
+ val userDid: String? = null,
+ val spaceDid: String? = null,
+ val users: List? = null,
+ val spaces: List? = null,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationRequest.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationRequest.kt
new file mode 100644
index 000000000..961c042be
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationRequest.kt
@@ -0,0 +1,7 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class DelegationRequest(
+ val userDid: String,
+ val spaceDid: String,
+ val expiresIn: Int = 24,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationRevokeRequest.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationRevokeRequest.kt
new file mode 100644
index 000000000..9b2d4cfbb
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DelegationRevokeRequest.kt
@@ -0,0 +1,6 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class DelegationRevokeRequest(
+ val userDid: String,
+ val spaceDid: String,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DidLoginRequest.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DidLoginRequest.kt
new file mode 100644
index 000000000..4b727d91d
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/DidLoginRequest.kt
@@ -0,0 +1,5 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class DidLoginRequest(
+ val did: String,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/IpldBlock.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/IpldBlock.kt
new file mode 100644
index 000000000..4d6d4664a
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/IpldBlock.kt
@@ -0,0 +1,6 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class IpldBlock(
+ val cid: ByteArray,
+ val data: ByteArray,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/LoginRequest.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/LoginRequest.kt
new file mode 100644
index 000000000..7b7fffab6
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/LoginRequest.kt
@@ -0,0 +1,6 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class LoginRequest(
+ val email: String,
+ val did: String,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/LoginResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/LoginResponse.kt
new file mode 100644
index 000000000..ec38dca43
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/LoginResponse.kt
@@ -0,0 +1,11 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class LoginResponse(
+ val message: String,
+ val sessionId: String,
+ val did: String,
+ val verified: Boolean,
+ val challenge: String? = null,
+ val challengeId: String? = null,
+ val spaces: List? = null,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/PlanInfo.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/PlanInfo.kt
new file mode 100644
index 000000000..240390d5d
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/PlanInfo.kt
@@ -0,0 +1,84 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class PlanInfo(
+ val name: String,
+ val storageLimit: Long, // in bytes
+ val egressLimit: Long, // in bytes
+ val monthlyCost: Double, // in USD
+ val additionalStorageCost: Double, // per GB per month
+ val description: String,
+) {
+ companion object {
+ fun fromPlanProduct(planProduct: String?): PlanInfo =
+ when {
+ planProduct?.contains("starter", ignoreCase = true) == true -> STARTER
+ planProduct?.contains("lite", ignoreCase = true) == true -> LITE
+ planProduct?.contains("business", ignoreCase = true) == true -> BUSINESS
+ else -> STARTER // Default to starter plan
+ }
+
+ val STARTER =
+ PlanInfo(
+ name = "Starter",
+ storageLimit = 5L * 1024 * 1024 * 1024, // 5GB
+ egressLimit = 5L * 1024 * 1024 * 1024, // 5GB
+ monthlyCost = 0.0,
+ additionalStorageCost = 0.15,
+ description = "Free tier with 5GB storage and egress",
+ )
+
+ val LITE =
+ PlanInfo(
+ name = "Lite",
+ storageLimit = 100L * 1024 * 1024 * 1024, // 100GB
+ egressLimit = 100L * 1024 * 1024 * 1024, // 100GB
+ monthlyCost = 10.0,
+ additionalStorageCost = 0.05,
+ description = "Cheapest per GB - 100GB storage and egress",
+ )
+
+ val BUSINESS =
+ PlanInfo(
+ name = "Business",
+ storageLimit = 2L * 1024 * 1024 * 1024 * 1024, // 2TB
+ egressLimit = 2L * 1024 * 1024 * 1024 * 1024, // 2TB
+ monthlyCost = 100.0,
+ additionalStorageCost = 0.03,
+ description = "Enterprise tier with 2TB storage and egress",
+ )
+ }
+
+ fun formatAllocation(): String {
+ val storage = formatBytes(storageLimit)
+ val additionalCostFormatted = String.format("$%.3f", additionalStorageCost)
+ return if (monthlyCost == 0.0) {
+ "$storage free, then $additionalCostFormatted/GB"
+ } else {
+ "$storage included, then $additionalCostFormatted/GB"
+ }
+ }
+
+ fun formatMonthlyCost(): String =
+ if (monthlyCost == 0.0) {
+ "Free"
+ } else {
+ String.format("$%.0f/month", monthlyCost)
+ }
+
+ private fun formatBytes(bytes: Long): String {
+ val units = arrayOf("B", "KB", "MB", "GB", "TB")
+ var size = bytes.toDouble()
+ var unitIndex = 0
+
+ while (size >= 1024 && unitIndex < units.size - 1) {
+ size /= 1024
+ unitIndex++
+ }
+
+ return if (size == size.toLong().toDouble()) {
+ "${size.toLong()}${units[unitIndex]}"
+ } else {
+ String.format("%.1f%s", size, units[unitIndex])
+ }
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/RevokeDelegationResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/RevokeDelegationResponse.kt
new file mode 100644
index 000000000..6d1e12013
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/RevokeDelegationResponse.kt
@@ -0,0 +1,8 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class RevokeDelegationResponse(
+ val message: String,
+ val userDid: String,
+ val spaceDid: String,
+ val revokedCount: Int,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SessionInfo.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SessionInfo.kt
new file mode 100644
index 000000000..0e4ac2175
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SessionInfo.kt
@@ -0,0 +1,8 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class SessionInfo(
+ val sessionId: String,
+ val createdAt: String,
+ val lastActive: String,
+ val isActive: Boolean,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SessionValidationResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SessionValidationResponse.kt
new file mode 100644
index 000000000..5be7d4294
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SessionValidationResponse.kt
@@ -0,0 +1,8 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class SessionValidationResponse(
+ val valid: Boolean,
+ val verified: Int,
+ val expiresAt: String,
+ val message: String,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SpaceInfo.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SpaceInfo.kt
new file mode 100644
index 000000000..09d0aa693
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SpaceInfo.kt
@@ -0,0 +1,7 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class SpaceInfo(
+ val did: String,
+ val name: String,
+ val isAdmin: Boolean,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SpaceUsageEntry.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SpaceUsageEntry.kt
new file mode 100644
index 000000000..019fc5d39
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SpaceUsageEntry.kt
@@ -0,0 +1,7 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class SpaceUsageEntry(
+ val spaceDid: String,
+ val name: String,
+ val usage: Usage,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SpaceUsageResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SpaceUsageResponse.kt
new file mode 100644
index 000000000..76f80c394
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/SpaceUsageResponse.kt
@@ -0,0 +1,6 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class SpaceUsageResponse(
+ val spaceDid: String,
+ val usage: Usage,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/StorachaAccount.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/StorachaAccount.kt
new file mode 100644
index 000000000..ae1cf66e5
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/StorachaAccount.kt
@@ -0,0 +1,11 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+/**
+ * Represents a logged-in Storacha account
+ */
+data class StorachaAccount(
+ val email: String,
+ val sessionId: String,
+ val isVerified: Boolean = false,
+ val did: String? = null,
+)
\ No newline at end of file
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/UploadEntry.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/UploadEntry.kt
new file mode 100644
index 000000000..1a13e54d3
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/UploadEntry.kt
@@ -0,0 +1,10 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class UploadEntry(
+ val cid: String,
+ val size: Long,
+ val created: String,
+ val insertedAt: String,
+ val updatedAt: String,
+ val gatewayUrl: String,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/UploadListResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/UploadListResponse.kt
new file mode 100644
index 000000000..e228b3a88
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/UploadListResponse.kt
@@ -0,0 +1,11 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class UploadListResponse(
+ val success: Boolean,
+ val userDid: String,
+ val spaceDid: String,
+ val uploads: List,
+ val count: Int,
+ val cursor: String?,
+ val hasMore: Boolean,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/UploadResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/UploadResponse.kt
new file mode 100644
index 000000000..783bb0e43
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/UploadResponse.kt
@@ -0,0 +1,7 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class UploadResponse(
+ val success: Boolean,
+ val cid: String,
+ val size: Long,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/Usage.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/Usage.kt
new file mode 100644
index 000000000..eb61a4d87
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/Usage.kt
@@ -0,0 +1,7 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class Usage(
+ val bytes: Long,
+ val mb: Double,
+ val human: String,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/UserDelegationResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/UserDelegationResponse.kt
new file mode 100644
index 000000000..c079eee9e
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/UserDelegationResponse.kt
@@ -0,0 +1,7 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class UserDelegationResponse(
+ val userDid: String,
+ val spaces: List,
+ val expiresAt: String?,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/VerifyRequest.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/VerifyRequest.kt
new file mode 100644
index 000000000..85a98cddd
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/VerifyRequest.kt
@@ -0,0 +1,9 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class VerifyRequest(
+ val did: String,
+ val challengeId: String,
+ val signature: String,
+ val sessionId: String,
+ val email: String? = null,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/VerifyResponse.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/VerifyResponse.kt
new file mode 100644
index 000000000..761c89f31
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/model/VerifyResponse.kt
@@ -0,0 +1,7 @@
+package net.opendasharchive.openarchive.services.storacha.model
+
+data class VerifyResponse(
+ val sessionId: String,
+ val did: String,
+ val message: String,
+)
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/network/AuthInterceptor.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/network/AuthInterceptor.kt
new file mode 100644
index 000000000..5af4cc22d
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/network/AuthInterceptor.kt
@@ -0,0 +1,61 @@
+package net.opendasharchive.openarchive.services.storacha.network
+
+import kotlinx.coroutines.runBlocking
+import net.opendasharchive.openarchive.services.storacha.util.SessionManager
+import okhttp3.Interceptor
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import timber.log.Timber
+
+/**
+ * OkHttp interceptor that handles 401 Unauthorized and 403 "Session not verified" responses.
+ * Simply returns 401 to trigger login flow - no auto-refresh.
+ *
+ * Uses lazy initialization to break circular dependency with SessionManager.
+ */
+class AuthInterceptor(
+ private val sessionManagerProvider: () -> SessionManager,
+) : Interceptor {
+ private val sessionManager by lazy { sessionManagerProvider() }
+
+ companion object {
+ private const val TAG = "AuthInterceptor"
+ }
+
+ /**
+ * Checks if a response contains "Session not verified" message
+ */
+ private fun isSessionNotVerifiedError(response: Response): Boolean =
+ try {
+ val errorBody = response.peekBody(Long.MAX_VALUE).string()
+ errorBody.contains("Session not verified", ignoreCase = true)
+ } catch (_: Exception) {
+ false
+ }
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+
+ // Skip intercepting auth endpoints to prevent infinite loops
+ val path = originalRequest.url.encodedPath
+ if (path.contains("/auth/")) {
+ return chain.proceed(originalRequest)
+ }
+
+ val originalResponse = chain.proceed(originalRequest)
+
+ // Handle 401 or 403 "Session not verified" - just return 401 to trigger login
+ if (originalResponse.code == 401 || (
+ originalResponse.code == 403 &&
+ isSessionNotVerifiedError(originalResponse)
+ )
+ ) {
+ Timber.tag(TAG).d("Received ${originalResponse.code} - sending to login")
+ // Just return the 401, let the UI handle it
+ return originalResponse
+ }
+
+ // Return the original response for all other cases
+ return originalResponse
+ }
+}
diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/service/BridgeApiService.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/service/BridgeApiService.kt
new file mode 100644
index 000000000..b6f59dda4
--- /dev/null
+++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/service/BridgeApiService.kt
@@ -0,0 +1,16 @@
+package net.opendasharchive.openarchive.services.storacha.service
+
+import net.opendasharchive.openarchive.services.storacha.model.BridgeTaskRequest
+import retrofit2.http.Body
+import retrofit2.http.Header
+import retrofit2.http.POST
+
+interface BridgeApiService {
+ @POST("bridge")
+ suspend fun callBridgeApi(
+ @Header("X-Auth-Secret") xAuthSecret: String,
+ @Header("Authorization") authorization: String,
+ @Header("Content-Type") contentType: String = "application/json",
+ @Body request: BridgeTaskRequest,
+ ): List
]]>
+ Storacha lets you store media securely using decentralized technologies (IPFS, UCAN, and DIDs).
+ Don\'t have an account?
+ Your email has been verified, and the spaces have been added to Save.
+ Email address
+ Users can delete the content within the space
+ Users can read the content within the space
+ Users can write content to the space
+ Define access to the following DID key
+ Here is your identity.\nShare this QR code or ID with an admin so they can add you to their space.
+ storacha qr
+ No DIDs currently have access to this space. Tap "Add" to grant access.
+ No Spaces Available
+ Loading spaces…
+ Loading files…
+ Loading more…
+ Loading accounts…
+ Uploading…
+ Logout
+ Are you sure you want to logout?
+ Please check your inbox and click the link on the verification email.
+ Verification Email Sent
+ Change now.]]>
+ Access
+ Email Verification
+ Add Account
+ Accounts
+ Spaces
+ No files Available
+ Connect to the Storacha network
+ Enter DID Key
+ Files
+ No Media Available.
+ Revoke Access?\n
+ "This will revoke access to this DID for this Space\n "
+ Revoke
+ View Spaces
+ Copy DID
+ Log in using your registered email address
+ Sent to: %1$s
+ Loading usage…
+ Manage Access
+ Once the admin approves you,\nthe space will appear under My Spaces.
+ Invalid DID format. Please scan a valid DID key (format: did:key:z...)
+ DID already added
+ Scan DID Key
+ DID access granted successfully
+ Add DID
+ Camera Permission
+ Camera access is needed to take pictures. Please grant permission.
+ Accept
+ Cancel
+
Sync email periodically
Download incoming attachments
@@ -389,6 +452,32 @@
Unable to connect to Orbot/Tor.
Unable to connect to Orbot/Tor: TIMEOUT
Unable to connect to Orbot/Tor: INVALID
+ Login
+ Logging in…
+ Adding DID…
+ Your session has expired. Please log out and log back in, then try uploading again.
+ You don\'t have permission to upload to this space. Please check with the space owner.
+ Can\'t connect to the server. Please check your internet connection and try again.
+ The storage service is temporarily unavailable. Please try again in a few minutes.
+ The file may be too large or the upload is taking too long. Try with a smaller file or check your connection.
+ The server is busy. Please wait a moment and try again.
+ Something went wrong on the server. Please try again.
+ There was an authentication issue. The app will try again automatically.
+ There was a problem with the storage service. Please try again.
+ There might not be enough storage space available. Please try again or contact support.
+ Something went wrong with the upload. Please try again.
+ To add someone, enter the DID key they shared with you or scan their QR code. They can find this information by tapping Join Space in their app.
+
+
+ Email Verification Required
+ Please verify your email to access spaces. Check your inbox for the verification link.
+ Session Expired
+ Your session has expired. Please login again to continue.
+ Your session has expired. You can login to access your spaces, or continue browsing delegated spaces without logging in.
+ Your session has expired. A new verification email has been sent to your inbox. Please check your email and click the verification link to continue.
+ Login
+ Stay Here
+ Retry Login
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
index 5e4ba9c97..2c1a3d1f7 100644
--- a/app/src/main/res/xml/network_security_config.xml
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -1,6 +1,6 @@
- localhost
+ save-storacha.staging.hypha.coopf
\ No newline at end of file
diff --git a/app/src/test/java/net/opendasharchive/openarchive/services/storacha/util/Ed25519UtilsTest.kt b/app/src/test/java/net/opendasharchive/openarchive/services/storacha/util/Ed25519UtilsTest.kt
new file mode 100644
index 000000000..f942c6903
--- /dev/null
+++ b/app/src/test/java/net/opendasharchive/openarchive/services/storacha/util/Ed25519UtilsTest.kt
@@ -0,0 +1,157 @@
+package net.opendasharchive.openarchive.services.storacha.util
+
+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
+import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
+import org.bouncycastle.jce.provider.BouncyCastleProvider
+import org.junit.Before
+import org.junit.Test
+import org.junit.Assert.*
+import java.security.Security
+
+class Ed25519UtilsTest {
+
+ @Before
+ fun setup() {
+ // Ensure BouncyCastle is registered for tests
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(BouncyCastleProvider())
+ }
+ }
+
+ @Test
+ fun testKeyPairGeneration() {
+ val keyPair = Ed25519Utils.generateKeyPair()
+
+ assertNotNull(keyPair)
+ assertNotNull(keyPair.private)
+ assertNotNull(keyPair.public)
+ assertTrue(keyPair.private is Ed25519PrivateKeyParameters)
+ assertTrue(keyPair.public is Ed25519PublicKeyParameters)
+ }
+
+ @Test
+ fun testDidCreation() {
+ val keyPair = Ed25519Utils.generateKeyPair()
+ val publicKey = keyPair.public as Ed25519PublicKeyParameters
+
+ val did = Ed25519Utils.createDidFromPublicKey(publicKey)
+
+ assertNotNull(did)
+ assertTrue(did.startsWith("did:key:z"))
+ assertTrue(did.length > 20) // Should be a reasonable length
+ }
+
+ @Test
+ fun testSignatureGeneration() {
+ val keyPair = Ed25519Utils.generateKeyPair()
+ val privateKey = keyPair.private as Ed25519PrivateKeyParameters
+ val challenge = "test-challenge-string"
+
+ val signature = Ed25519Utils.signChallenge(challenge, privateKey)
+
+ assertNotNull(signature)
+ assertTrue(signature.isNotEmpty())
+ }
+
+ @Test
+ fun testSignatureVerification() {
+ val keyPair = Ed25519Utils.generateKeyPair()
+ val privateKey = keyPair.private as Ed25519PrivateKeyParameters
+ val publicKey = keyPair.public as Ed25519PublicKeyParameters
+ val challenge = "test-challenge-string"
+
+ val signature = Ed25519Utils.signChallenge(challenge, privateKey)
+ val isValid = Ed25519Utils.verifySignature(challenge, signature, publicKey)
+
+ assertTrue(isValid)
+ }
+
+ @Test
+ fun testInvalidSignatureVerification() {
+ val keyPair1 = Ed25519Utils.generateKeyPair()
+ val keyPair2 = Ed25519Utils.generateKeyPair()
+ val privateKey = keyPair1.private as Ed25519PrivateKeyParameters
+ val wrongPublicKey = keyPair2.public as Ed25519PublicKeyParameters
+ val challenge = "test-challenge-string"
+
+ val signature = Ed25519Utils.signChallenge(challenge, privateKey)
+ val isValid = Ed25519Utils.verifySignature(challenge, signature, wrongPublicKey)
+
+ assertFalse(isValid)
+ }
+
+ @Test
+ fun testDidToPublicKeyExtraction() {
+ val keyPair = Ed25519Utils.generateKeyPair()
+ val originalPublicKey = keyPair.public as Ed25519PublicKeyParameters
+ val did = Ed25519Utils.createDidFromPublicKey(originalPublicKey)
+
+ val extractedPublicKey = Ed25519Utils.extractPublicKeyFromDid(did)
+
+ assertNotNull(extractedPublicKey)
+ assertArrayEquals(originalPublicKey.encoded, extractedPublicKey!!.encoded)
+ }
+
+ @Test
+ fun testValidDidValidation() {
+ // Generate a valid DID
+ val keyPair = Ed25519Utils.generateKeyPair()
+ val publicKey = keyPair.public as Ed25519PublicKeyParameters
+ val validDid = Ed25519Utils.createDidFromPublicKey(publicKey)
+
+ // Test that it validates correctly
+ assertTrue(Ed25519Utils.isValidDid(validDid))
+ }
+
+ @Test
+ fun testInvalidDidValidation_EmptyString() {
+ assertFalse(Ed25519Utils.isValidDid(""))
+ }
+
+ @Test
+ fun testInvalidDidValidation_NoPrefix() {
+ assertFalse(Ed25519Utils.isValidDid("z6MkjTHQxjZh7sQZ7sZBvJxDqyzYb4nKq1iWzWUzRr3oT1XB"))
+ }
+
+ @Test
+ fun testInvalidDidValidation_WrongPrefix() {
+ assertFalse(Ed25519Utils.isValidDid("did:web:example.com"))
+ }
+
+ @Test
+ fun testInvalidDidValidation_IncompletePrefix() {
+ assertFalse(Ed25519Utils.isValidDid("did:key:"))
+ }
+
+ @Test
+ fun testInvalidDidValidation_MissingZPrefix() {
+ assertFalse(Ed25519Utils.isValidDid("did:key:6MkjTHQxjZh7sQZ7sZBvJxDqyzYb4nKq1iWzWUzRr3oT1XB"))
+ }
+
+ @Test
+ fun testInvalidDidValidation_RandomString() {
+ assertFalse(Ed25519Utils.isValidDid("not-a-valid-did"))
+ }
+
+ @Test
+ fun testInvalidDidValidation_InvalidBase58() {
+ // Contains invalid Base58 characters (0, O, I, l)
+ assertFalse(Ed25519Utils.isValidDid("did:key:z0OIl123456789"))
+ }
+
+ @Test
+ fun testInvalidDidValidation_TooShort() {
+ assertFalse(Ed25519Utils.isValidDid("did:key:z"))
+ }
+
+ @Test
+ fun testValidDidFromExample() {
+ // Test with a known valid DID format from the codebase
+ val exampleDid = "did:key:z6MkjTHQxjZh7sQZ7sZBvJxDqyzYb4nKq1iWzWUzRr3oT1XB"
+ // Note: This may or may not be valid depending on the actual key encoding
+ // but it has the correct format structure
+ val result = Ed25519Utils.isValidDid(exampleDid)
+ // We're testing that the function handles it without crashing
+ assertNotNull(result)
+ }
+}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a30d8e7e3..09dc45534 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -43,6 +43,7 @@ gson = "2.13.2"
guava = "31.0.1-jre"
guava-listenablefuture = "9999.0-empty-to-avoid-conflict-with-guava"
j2v8 = "6.2.1@aar"
+jsoup = "1.17.2"
jtorctl = "0.4.5.7"
junit = "4.13.2"
junit-android = "1.3.0"
@@ -60,6 +61,7 @@ navigationevent = "1.0.1"
netcipher = "2.2.0-alpha"
okhttp = "4.12.0"
permissionx = "1.8.1"
+picasso = "2.8"
preference = "1.2.1"
proofmode = "1.0.30"
recyclerview = "1.4.0"
@@ -176,6 +178,12 @@ firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashly
#google-http-client-gson = { group = "com.google.http-client", name = "google-http-client-gson", version.ref = "google-http-client-gson" }
#google-drive-api = { group = "com.google.apis", name = "google-api-services-drive", version = "v3-rev136-1.25.0" }
+# Google
+jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
+picasso = { module = "com.squareup.picasso:picasso", version.ref = "picasso" }
+zxing-core = { module = "com.google.zxing:core", version.ref = "zxing-core" }
+zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxing-android-embedded" }
+
# Google - Material Design
google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
@@ -242,8 +250,6 @@ proofmode = { group = "org.proofmode", name = "android-libproofmode", version.re
satyan-sugar = { group = "com.github.satyan", name = "sugar", version.ref = "satyan-sugar" }
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
tor-android = { group = "info.guardianproject", name = "tor-android", version.ref = "tor-android" }
-zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing-core" }
-zxing-android-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxing-android-embedded" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }