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> +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/service/StorachaApiService.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/service/StorachaApiService.kt new file mode 100644 index 000000000..9385c73a0 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/service/StorachaApiService.kt @@ -0,0 +1,159 @@ +package net.opendasharchive.openarchive.services.storacha.service + +import net.opendasharchive.openarchive.services.storacha.model.AccountUsageResponse +import net.opendasharchive.openarchive.services.storacha.model.BridgeTokenRequest +import net.opendasharchive.openarchive.services.storacha.model.BridgeTokenResponse +import net.opendasharchive.openarchive.services.storacha.model.DelegationCreateResponse +import net.opendasharchive.openarchive.services.storacha.model.DelegationDetailsResponse +import net.opendasharchive.openarchive.services.storacha.model.DelegationListResponse +import net.opendasharchive.openarchive.services.storacha.model.DelegationRequest +import net.opendasharchive.openarchive.services.storacha.model.DelegationRevokeRequest +import net.opendasharchive.openarchive.services.storacha.model.DidLoginRequest +import net.opendasharchive.openarchive.services.storacha.model.LoginRequest +import net.opendasharchive.openarchive.services.storacha.model.LoginResponse +import net.opendasharchive.openarchive.services.storacha.model.RevokeDelegationResponse +import net.opendasharchive.openarchive.services.storacha.model.SessionInfo +import net.opendasharchive.openarchive.services.storacha.model.SessionValidationResponse +import net.opendasharchive.openarchive.services.storacha.model.SpaceInfo +import net.opendasharchive.openarchive.services.storacha.model.SpaceUsageResponse +import net.opendasharchive.openarchive.services.storacha.model.UploadListResponse +import net.opendasharchive.openarchive.services.storacha.model.UploadResponse +import net.opendasharchive.openarchive.services.storacha.model.UserDelegationResponse +import net.opendasharchive.openarchive.services.storacha.model.VerifyRequest +import net.opendasharchive.openarchive.services.storacha.model.VerifyResponse +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.HTTP +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +interface StorachaApiService { + @POST("auth/login") + suspend fun login( + @Body request: LoginRequest, + ): LoginResponse + + @POST("auth/login/did") + suspend fun loginWithDid( + @Body request: DidLoginRequest, + ): LoginResponse + + @POST("auth/verify") + suspend fun verify( + @Body request: VerifyRequest, + ): VerifyResponse + + @GET("auth/session") + suspend fun validateSession( + @Header("x-session-id") sessionId: String, + ): SessionValidationResponse + + @POST("auth/logout") + suspend fun logout( + @Header("x-session-id") sessionId: String, + ): Void + + @POST("auth/w3up/logout") + suspend fun logoutW3up( + @Header("x-session-id") sessionId: String, + ): Void + + @GET("auth/sessions") + suspend fun listSessions( + @Header("x-session-id") sessionId: String, + ): List + + @POST("auth/sessions/{id}/deactivate") + suspend fun deactivateSession( + @Header("x-session-id") sessionId: String, + @Path("id") id: String, + ): Void + + @POST("auth/sessions/deactivate-all") + suspend fun deactivateAllSessions( + @Header("x-session-id") sessionId: String, + ): Void + + @GET("spaces") + suspend fun listSpaces( + @Header("x-user-did") userDid: String, + @Header("x-session-id") sessionId: String, + ): List + + @GET("spaces/usage") + suspend fun getSpaceUsage( + @Header("x-session-id") sessionId: String, + @Header("x-user-did") userDid: String, + @Query("spaceDid") spaceDid: String, + ): SpaceUsageResponse + + @GET("spaces/account-usage") + suspend fun getAccountUsage( + @Header("x-session-id") sessionId: String, + ): AccountUsageResponse + + @GET("uploads") + suspend fun listUploads( + @Header("x-user-did") userDid: String, + @Header("x-session-id") sessionId: String?, + @Query("spaceDid") spaceDid: String, + @Query("cursor") cursor: String? = null, + @Query("size") size: Int? = null, + ): UploadListResponse + + @Multipart + @POST("upload") + suspend fun uploadFile( + @Header("x-user-did") userDid: String, + @Part file: MultipartBody.Part, + @Part("spaceDid") spaceDid: RequestBody, + ): UploadResponse + + @POST("bridge-tokens") + suspend fun getBridgeTokens( + @Header("x-user-did") userDid: String?, + @Header("x-session-id") sessionId: String?, + @Body request: BridgeTokenRequest, + ): BridgeTokenResponse + + @GET("delegations/user/spaces") + suspend fun getUserSpaces( + @Header("x-user-did") userDid: String, + ): UserDelegationResponse + + @GET("delegations/list") + suspend fun listDelegationsByUser( + @Header("x-session-id") sessionId: String, + @Query("userDid") userDid: String, + ): DelegationListResponse + + @GET("delegations/list") + suspend fun listDelegationsBySpace( + @Header("x-session-id") sessionId: String, + @Query("spaceDid") spaceDid: String, + ): DelegationListResponse + + @POST("delegations/create") + suspend fun createDelegation( + @Header("x-session-id") sessionId: String, + @Body request: DelegationRequest, + ): DelegationCreateResponse + + @GET("delegations/get") + suspend fun getDelegationDetails( + @Header("x-user-did") userDid: String, + @Query("spaceDid") spaceDid: String, + ): DelegationDetailsResponse + + @HTTP(method = "DELETE", path = "delegations/revoke", hasBody = true) + suspend fun revokeDelegation( + @Header("x-session-id") sessionId: String, + @Body request: DelegationRevokeRequest, + ): RevokeDelegationResponse +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/BridgeUploader.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/BridgeUploader.kt new file mode 100644 index 000000000..81c6809fb --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/BridgeUploader.kt @@ -0,0 +1,518 @@ +package net.opendasharchive.openarchive.services.storacha.util + +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import net.opendasharchive.openarchive.services.storacha.model.BridgeTaskRequest +import net.opendasharchive.openarchive.services.storacha.model.BridgeTokenRequest +import net.opendasharchive.openarchive.services.storacha.model.BridgeTokens +import net.opendasharchive.openarchive.services.storacha.model.BridgeUploadResult +import net.opendasharchive.openarchive.services.storacha.model.StoreAddResponse +import net.opendasharchive.openarchive.services.storacha.model.StoreAddTask +import net.opendasharchive.openarchive.services.storacha.model.UploadAddResponse +import net.opendasharchive.openarchive.services.storacha.model.UploadAddTask +import net.opendasharchive.openarchive.services.storacha.service.StorachaApiService +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import retrofit2.Retrofit +import java.io.File +import retrofit2.converter.gson.GsonConverterFactory +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * Fixed BridgeUploader that addresses "unexpected end of data" issues + */ +class BridgeUploader( + private val client: OkHttpClient = + OkHttpClient + .Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .build(), + private val gson: Gson = Gson(), +) { + // Separate client for S3 uploads without logging to avoid OOM on large files + private val s3Client = OkHttpClient + .Builder() + .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() + private val storachaService = + Retrofit + .Builder() + .baseUrl("http://save-storacha.staging.hypha.coop:3000/") + .client(client) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(StorachaApiService::class.java) + + private val bridgeService = + Retrofit + .Builder() + .baseUrl("https://up.storacha.network/") + .client(client) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(net.opendasharchive.openarchive.services.storacha.service.BridgeApiService::class.java) + + suspend fun uploadFile( + carFile: File, + carCid: String, + rootCid: String, + spaceDid: String, + userDid: String? = null, + sessionId: String? = null, + isAdmin: Boolean = false, + ): BridgeUploadResult = + withContext(Dispatchers.IO) { + // Validate CID formats + if (!carCid.startsWith("bag")) { + throw IllegalArgumentException("CAR CID must start with 'bag', got: $carCid") + } + if (!rootCid.startsWith("bafy")) { + throw IllegalArgumentException("Root CID must start with 'bafy', got: $rootCid") + } + + val carSize = carFile.length() + Timber.e("Starting bridge upload - CAR CID: $carCid, Root CID: $rootCid, Size: $carSize") + + try { + // Step 1: Generate bridge tokens IMMEDIATELY before use + val tokens = generateBridgeTokens(spaceDid, userDid, sessionId, isAdmin) + + // Add small delay to ensure token propagation + kotlinx.coroutines.delay(100) + + // Step 2: Call store/add to get S3 pre-signed URL + val storeResult = + storeAddWithRetry( + tokens, + spaceDid, + carCid, + carSize, + 0, + userDid, + sessionId, + isAdmin, + ) + + // Step 3: Upload to S3 (if required) + if (storeResult.status == "upload" && storeResult.url != null) { + Timber.e("S3 upload required") + try { + uploadToS3(carFile, storeResult.url, storeResult.headers ?: emptyMap()) + Timber.e("S3 upload completed") + } catch (s3Error: Exception) { + // Check if S3 error is due to expired token/URL - regenerate and retry once + if (s3Error.message?.contains("InvalidToken", ignoreCase = true) == true || + s3Error.message?.contains("403") == true + ) { + Timber.e("S3 upload failed with token/permission error, regenerating tokens and retrying...") + + // Regenerate tokens and get fresh S3 URL + val newTokens = generateBridgeTokens(spaceDid, userDid, sessionId, isAdmin) + kotlinx.coroutines.delay(100) // Brief delay for token propagation + val newStoreResult = + storeAddWithRetry( + newTokens, + spaceDid, + carCid, + carSize, + 0, + userDid, + sessionId, + isAdmin, + ) + + if (newStoreResult.status == "upload" && newStoreResult.url != null) { + uploadToS3( + carFile, + newStoreResult.url, + newStoreResult.headers ?: emptyMap(), + ) + Timber.e("S3 upload completed after retry") + } + } else { + throw s3Error // Re-throw if not a token issue + } + } + } else { + Timber.e("File already uploaded, status: ${storeResult.status}") + } + + // Step 4: Register upload with upload/add + val uploadResult = + uploadAddWithRetry(tokens, spaceDid, rootCid, 0, userDid, sessionId, isAdmin) + Timber.e("Upload registered successfully") + + BridgeUploadResult( + rootCid = uploadResult.root.getValue("/"), + carCid = carCid, + size = carSize, + ) + } catch (e: Exception) { + Timber.e("Bridge upload failed: ${e.message}") + Timber.e("Stack trace: ${e.stackTrace.joinToString("\n")}") + throw e + } + } + + private suspend fun generateBridgeTokens( + spaceDid: String, + userDid: String?, + sessionId: String?, + isAdmin: Boolean = false, + ): BridgeTokens { + // Generate expiration timestamp exactly like working debug script + val currentTimeSeconds = System.currentTimeMillis() / 1000 + val expirationMillis = (currentTimeSeconds * 1000) + (60 * 60 * 1000) // 1 hour from now + + Timber.e("Token expiration: $expirationMillis (current: ${System.currentTimeMillis()})") + + val request = + BridgeTokenRequest( + resource = spaceDid, + can = listOf("store/add", "upload/add"), + expiration = expirationMillis, + json = false, + ) + + try { + // Send sessionId only when isAdmin = true, otherwise send userDid + val response = if (isAdmin) { + storachaService.getBridgeTokens(null, sessionId, request) + } else { + storachaService.getBridgeTokens(userDid, null, request) + } + + // Log token details for debugging + Timber.e("Generated tokens - X-Auth-Secret length: ${response.tokens.xAuthSecret.length}") + Timber.e("Authorization length: ${response.tokens.authorization.length}") + + return response.tokens + } catch (e: retrofit2.HttpException) { + val errorBody = e.response()?.errorBody()?.string() ?: "No error details" + val httpCode = e.code() + val httpMessage = e.message() + + Timber.e("Token generation HTTP error: $httpCode $httpMessage") + Timber.e("Token generation error body: $errorBody") + + // Re-throw HttpException so AuthInterceptor can handle 401/403 properly + throw e + } + } + + /** + * Store/add with retry logic for token expiration + */ + private suspend fun storeAddWithRetry( + tokens: BridgeTokens, + spaceDid: String, + carCid: String, + carSize: Long, + retryCount: Int = 0, + userDid: String? = null, + sessionId: String? = null, + isAdmin: Boolean = false, + ): net.opendasharchive.openarchive.services.storacha.model.StoreAddSuccess { + val storeTask = + StoreAddTask( + link = mapOf("/" to carCid), + size = carSize, + ) + + val taskRequest = + BridgeTaskRequest( + tasks = + listOf( + listOf("store/add", spaceDid, storeTask), + ), + ) + + Timber.e("store/add request JSON: ${gson.toJson(taskRequest)}") + + try { + val responses = + bridgeService.callBridgeApi( + tokens.xAuthSecret, + tokens.authorization, + "application/json", + taskRequest, + ) + + val responseJson = gson.toJson(responses[0]) + Timber.e("store/add response: $responseJson") + + val storeResponse = gson.fromJson(responseJson, StoreAddResponse::class.java) + + if (storeResponse.p.out.error != null) { + val errorMsg = + storeResponse.p.out.error + .toString() + Timber.e("Bridge store/add error: $errorMsg") + + // Check for token expiration and retry once with new tokens + if (retryCount == 0 && (errorMsg.contains("expired") || errorMsg.contains("delegation"))) { + Timber.e("Token expired or delegation issue, regenerating tokens and retrying...") + kotlinx.coroutines.delay(500) // Brief delay + val newTokens = generateBridgeTokens(spaceDid, userDid, sessionId, isAdmin) + return storeAddWithRetry( + newTokens, + spaceDid, + carCid, + carSize, + retryCount + 1, + userDid, + sessionId, + isAdmin, + ) + } + + throw Exception("Bridge store/add error: $errorMsg") + } + + return storeResponse.p.out.ok!! + } catch (e: retrofit2.HttpException) { + val errorBody = e.response()?.errorBody()?.string() ?: "No error details" + val httpCode = e.code() + val httpMessage = e.message() + + Timber.e("store/add HTTP error: $httpCode $httpMessage") + Timber.e("store/add error body: $errorBody") + + // Check for token-related HTTP errors and retry with fresh tokens + if (retryCount == 0 && ( + httpCode == 401 || httpCode == 403 || + errorBody.contains("InvalidToken", ignoreCase = true) || + errorBody.contains("expired", ignoreCase = true) || + errorBody.contains("delegation", ignoreCase = true) + ) + ) { + Timber.e("HTTP $httpCode suggests token issue, regenerating tokens and retrying...") + kotlinx.coroutines.delay(500) + val newTokens = generateBridgeTokens(spaceDid, userDid, sessionId) + return storeAddWithRetry( + newTokens, + spaceDid, + carCid, + carSize, + retryCount + 1, + userDid, + sessionId, + isAdmin, + ) + } + + val detailedError = + "HTTP $httpCode: $httpMessage${if (errorBody != "No error details") "\nServer response: $errorBody" else ""}" + throw Exception("Bridge store/add failed - $detailedError") + } catch (e: Exception) { + Timber.e("store/add failed: ${e.message}") + + // Check for "unexpected end of data" and provide debugging info + if (e.message?.contains("unexpected end of data") == true) { + Timber.e("🎯 Unexpected end of data detected!") + Timber.e("CAR CID: $carCid") + Timber.e("CAR size: $carSize") + Timber.e("Space DID: $spaceDid") + Timber.e("Token X-Auth-Secret starts with: ${tokens.xAuthSecret.take(20)}...") + Timber.e("Request JSON length: ${gson.toJson(taskRequest).length}") + } + + throw e + } + } + + private suspend fun uploadToS3( + carFile: File, + url: String, + headers: Map, + ) { + val requestBuilder = + Request + .Builder() + .url(url) + .put(carFile.asRequestBody("application/vnd.ipld.car".toMediaType())) + + // Add explicit Content-Length header - S3 requires exact match + requestBuilder.addHeader("Content-Length", carFile.length().toString()) + + headers.forEach { (key, value) -> + requestBuilder.addHeader(key, value) + } + + // Use withTimeout for S3 upload with 5 minute timeout for large files + val response = + withTimeout(300_000L) { + // 5 minutes + suspendCancellableCoroutine { continuation -> + // Use s3Client without logging to avoid OOM on large files + val call = s3Client.newCall(requestBuilder.build()) + + call.enqueue( + object : okhttp3.Callback { + override fun onFailure( + call: okhttp3.Call, + e: java.io.IOException, + ) { + continuation.resumeWith(Result.failure(e)) + } + + override fun onResponse( + call: okhttp3.Call, + response: okhttp3.Response, + ) { + continuation.resumeWith(Result.success(response)) + } + }, + ) + + continuation.invokeOnCancellation { + call.cancel() + } + } + } + + if (!response.isSuccessful) { + val responseBody = response.body?.string() ?: "No response body" + val responseHeaders = response.headers.toMultimap().toString() + + Timber.e("S3 upload failed - Code: ${response.code}, Message: ${response.message}") + Timber.e("S3 response body: $responseBody") + Timber.e("S3 response headers: $responseHeaders") + Timber.e("CAR file size: ${carFile.length()} bytes") + + response.close() + + val detailedError = + "S3 upload failed - HTTP ${response.code}: ${response.message}" + + if (responseBody != "No response body") "\nS3 response: $responseBody" else "" + throw Exception(detailedError) + } + response.close() + } + + /** + * Upload/add with retry logic for token expiration + */ + private suspend fun uploadAddWithRetry( + tokens: BridgeTokens, + spaceDid: String, + rootCid: String, + retryCount: Int = 0, + userDid: String? = null, + sessionId: String? = null, + isAdmin: Boolean = false, + ): net.opendasharchive.openarchive.services.storacha.model.UploadAddSuccess { + val uploadTask = + UploadAddTask( + root = mapOf("/" to rootCid), + ) + + val taskRequest = + BridgeTaskRequest( + tasks = + listOf( + listOf("upload/add", spaceDid, uploadTask), + ), + ) + + Timber.e("upload/add request JSON: ${gson.toJson(taskRequest)}") + + try { + val responses = + bridgeService.callBridgeApi( + tokens.xAuthSecret, + tokens.authorization, + "application/json", + taskRequest, + ) + + val responseJson = gson.toJson(responses[0]) + Timber.e("upload/add response: $responseJson") + + val uploadResponse = gson.fromJson(responseJson, UploadAddResponse::class.java) + + if (uploadResponse.p.out.error != null) { + val errorMsg = + uploadResponse.p.out.error + .toString() + Timber.e("Bridge upload/add error: $errorMsg") + + // Check for token expiration and retry once with new tokens + if (retryCount == 0 && (errorMsg.contains("expired") || errorMsg.contains("delegation"))) { + Timber.e("Token expired or delegation issue in upload/add, regenerating tokens and retrying...") + kotlinx.coroutines.delay(500) // Brief delay + val newTokens = generateBridgeTokens(spaceDid, userDid, sessionId, isAdmin) + return uploadAddWithRetry( + newTokens, + spaceDid, + rootCid, + retryCount + 1, + userDid, + sessionId, + isAdmin, + ) + } + + throw Exception("Bridge upload/add error: $errorMsg") + } + + return uploadResponse.p.out.ok!! + } catch (e: retrofit2.HttpException) { + val errorBody = e.response()?.errorBody()?.string() ?: "No error details" + val httpCode = e.code() + val httpMessage = e.message() + + Timber.e("upload/add HTTP error: $httpCode $httpMessage") + Timber.e("upload/add error body: $errorBody") + + // Check for token-related HTTP errors and retry with fresh tokens + if (retryCount == 0 && ( + httpCode == 401 || httpCode == 403 || + errorBody.contains("InvalidToken", ignoreCase = true) || + errorBody.contains("expired", ignoreCase = true) || + errorBody.contains("delegation", ignoreCase = true) + ) + ) { + Timber.e("HTTP $httpCode suggests token issue in upload/add, regenerating tokens and retrying...") + kotlinx.coroutines.delay(500) + val newTokens = generateBridgeTokens(spaceDid, userDid, sessionId) + return uploadAddWithRetry( + newTokens, + spaceDid, + rootCid, + retryCount + 1, + userDid, + sessionId, + isAdmin, + ) + } + + val detailedError = + "HTTP $httpCode: $httpMessage${if (errorBody != "No error details") "\nServer response: $errorBody" else ""}" + throw Exception("Bridge upload/add failed - $detailedError") + } catch (e: Exception) { + Timber.e("upload/add failed: ${e.message}") + + // Check for "unexpected end of data" and provide debugging info + if (e.message?.contains("unexpected end of data") == true) { + Timber.e("🎯 Unexpected end of data detected in upload/add!") + Timber.e("Root CID: $rootCid") + Timber.e("Space DID: $spaceDid") + Timber.e("Request JSON length: ${gson.toJson(taskRequest).length}") + } + + throw e + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/CarFileCreator.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/CarFileCreator.kt new file mode 100644 index 000000000..d9f9b86d6 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/CarFileCreator.kt @@ -0,0 +1,539 @@ +package net.opendasharchive.openarchive.services.storacha.util + +import net.opendasharchive.openarchive.services.storacha.model.IpldBlock +import java.io.BufferedOutputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.security.MessageDigest + +data class CarFileResult( + val carFile: File, + val carCid: String, + val rootCid: String, + val carSize: Long, +) + +object CarFileCreator { + private const val CHUNK_SIZE = 1048576 // 1MB chunks to match ipfs-car default + + fun createCarFile(file: File, outputDir: File? = null): CarFileResult { + // ipfs-car uses embedded approach for files < 1MB, chunked approach for larger files + return if (file.length() < CHUNK_SIZE) { + createEmbeddedCarWithCids(file, outputDir) + } else { + createChunkedCarWithCids(file, outputDir) + } + } + + /** + * Creates CAR file using embedded approach for small files (< 1MB) - like ipfs-car does + */ + private fun createEmbeddedCarWithCids(file: File, outputDir: File?): CarFileResult { + val blocks = mutableListOf() + val fileData = file.readBytes() + + // Create raw block for the file data + val rawBlock = createRawBlock(fileData) + blocks.add(rawBlock) + + // Create single DAG-PB block that directly links to the raw block with filename + // This is the "embedded" approach - no intermediate block + val rootDagPbBlock = createEmbeddedDagPbBlock(rawBlock.cid, file.name, file.length()) + blocks.add(rootDagPbBlock) + + // Create CIDs in proper format + val rootCid = cidBytesToString(rootDagPbBlock.cid) + + // Create temporary CAR file + val carFile = File.createTempFile("car_", ".car", outputDir ?: file.parentFile) + + // Write CAR data directly to file + writeCarToFile(carFile, rootDagPbBlock.cid, blocks) + + // Calculate CAR CID from the file + val carCid = createCarCidFromFile(carFile) + + return CarFileResult(carFile, carCid, rootCid, carFile.length()) + } + + /** + * Creates a DAG-PB block that directly links to the raw file block with filename (embedded approach) + */ + private fun createEmbeddedDagPbBlock( + rawCid: ByteArray, + fileName: String, + fileSize: Long, + ): IpldBlock { + // Create minimal UnixFS root node (directory-like) + val unixfsData = byteArrayOf(0x08, 0x01) // type = directory (1) + + // Create link to raw block with filename + val linkData = createPbLink(rawCid, fileName, fileSize) + + // Create DAG-PB protobuf structure: Link first, then Data (field 2, then field 1 - ipfs-car order) + val pbData = + ByteArrayOutputStream() + .apply { + // Field 2: Link to raw block first + write(0x12) // field 2, wire type 2 + write(encodeVarInt(linkData.size)) + write(linkData) + + // Field 1: Data (UnixFS metadata) + write(0x0A) // field 1, wire type 2 + write(encodeVarInt(unixfsData.size)) + write(unixfsData) + }.toByteArray() + + // Create CID for the DAG-PB block + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(pbData) + val multiHash = byteArrayOf(0x12, 0x20) + hash + val cidBytes = byteArrayOf(0x01.toByte(), 0x70.toByte()) + multiHash + + return IpldBlock(cidBytes, pbData) + } + + private fun createChunkedCarWithCids(file: File, outputDir: File?): CarFileResult { + val chunkCids = mutableListOf() + + // Create temporary CAR file + val carFile = File.createTempFile("car_", ".car", outputDir ?: file.parentFile) + + // Phase 1: Stream file chunks and write raw blocks directly to CAR file + // We'll write blocks in two passes - first pass for raw blocks, second for metadata + val tempBlocksFile = File.createTempFile("car_blocks_", ".tmp", outputDir ?: file.parentFile) + + try { + BufferedOutputStream(FileOutputStream(tempBlocksFile), 8192).use { blockOutput -> + // Stream the file in chunks to avoid loading entire file into memory + FileInputStream(file).use { inputStream -> + val buffer = ByteArray(CHUNK_SIZE) + var bytesRead: Int + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + // Calculate CID directly from buffer without copying + val cidBytes = calculateRawBlockCid(buffer, bytesRead) + chunkCids.add(cidBytes) + + // Write block directly to temp file + val blockSize = cidBytes.size + bytesRead + blockOutput.write(encodeVarInt(blockSize)) + blockOutput.write(cidBytes) + blockOutput.write(buffer, 0, bytesRead) // Write only actual bytes read + + // Buffer will be reused in next iteration - no additional allocations needed + } + } + } + + // Phase 2: Create metadata blocks (these are small, OK to keep in memory) + val intermediateDagPbBlock = + createIntermediateDagPbBlock(chunkCids, file.length()) + val rootDagPbBlock = + createRootDagPbBlock(intermediateDagPbBlock.cid, file.name, file.length()) + val rootCid = cidBytesToString(rootDagPbBlock.cid) + + // Phase 3: Assemble final CAR file + BufferedOutputStream(FileOutputStream(carFile), 8192).use { carOutput -> + // Write CAR header + val headerData = createCborHeader(rootDagPbBlock.cid) + carOutput.write(encodeVarInt(headerData.size)) + carOutput.write(headerData) + + // Copy all raw blocks from temp file + FileInputStream(tempBlocksFile).use { tempInput -> + val copyBuffer = ByteArray(8192) + var copied: Int + while (tempInput.read(copyBuffer).also { copied = it } != -1) { + carOutput.write(copyBuffer, 0, copied) + } + } + + // Write metadata blocks + val metadataBlocks = listOf(intermediateDagPbBlock, rootDagPbBlock) + for (block in metadataBlocks) { + val blockSize = block.cid.size + block.data.size + carOutput.write(encodeVarInt(blockSize)) + carOutput.write(block.cid) + carOutput.write(block.data) + } + + carOutput.flush() + } + + // Calculate CAR CID from the file + val carCid = createCarCidFromFile(carFile) + + return CarFileResult(carFile, carCid, rootCid, carFile.length()) + } finally { + // Clean up temp blocks file + if (tempBlocksFile.exists()) { + tempBlocksFile.delete() + } + } + } + +// private fun createLargeFileCarWithCids(file: File, mimeType: String): CarFileResult { +// val blocks = mutableListOf() +// val chunkCids = mutableListOf() +// +// // Read file in chunks and create raw blocks +// FileInputStream(file).use { inputStream -> +// val buffer = ByteArray(CHUNK_SIZE) +// var bytesRead: Int +// +// while (inputStream.read(buffer).also { bytesRead = it } != -1) { +// val chunkData = if (bytesRead < CHUNK_SIZE) { +// buffer.copyOf(bytesRead) +// } else { +// buffer.copyOf() +// } +// +// val rawBlock = createRawBlock(chunkData) +// blocks.add(rawBlock) +// chunkCids.add(rawBlock.cid) +// } +// } +// +// // Create DAG-PB block that links to all chunks +// val dagPbBlock = createChunkedDagPbBlock(chunkCids, file.name, file.length(), mimeType) +// blocks.add(dagPbBlock) +// +// // Create CAR with all blocks, header points to DAG-PB root +// val carData = createCar(dagPbBlock.cid, blocks) +// +// // Create CIDs in proper format +// val rootCid = cidBytesToString(dagPbBlock.cid, "bafy") +// val carCid = createCarCid(carData) +// +// return CarFileResult(carData, carCid, rootCid) +// } + + /** + * Creates a raw IPLD block (codec 0x55) containing the file data + */ + private fun createRawBlock(data: ByteArray): IpldBlock { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(data) + + // Create multihash: 0x12 (SHA-256) + 0x20 (32 bytes) + hash + val multiHash = byteArrayOf(0x12, 0x20) + hash + // Create CID v1: version(1) + codec(0x55 raw) + multihash + val cidBytes = byteArrayOf(0x01.toByte(), 0x55.toByte()) + multiHash + + return IpldBlock(cidBytes, data) + } + + /** + * Calculates CID for raw block data without creating a copy + * This is used for streaming large files to avoid memory allocation + */ + private fun calculateRawBlockCid(buffer: ByteArray, length: Int): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(buffer, 0, length) + val hash = digest.digest() + + // Create multihash: 0x12 (SHA-256) + 0x20 (32 bytes) + hash + val multiHash = byteArrayOf(0x12, 0x20) + hash + // Create CID v1: version(1) + codec(0x55 raw) + multihash + return byteArrayOf(0x01.toByte(), 0x55.toByte()) + multiHash + } + + /** + * Creates an intermediate DAG-PB block that links to raw blocks (like ipfs-car intermediate block) + */ + private fun createIntermediateDagPbBlock( + chunkCids: List, + fileSize: Long, + ): IpldBlock { + // Create UnixFS metadata for intermediate block (matches ipfs-car structure) + val unixfsData = createIntermediateUnixFsData(fileSize, chunkCids.size) + + // Create DAG-PB protobuf structure: Links first, then Data (field 2, then field 1 - ipfs-car order) + val pbData = + ByteArrayOutputStream() + .apply { + // Field 2: Links to all chunks first - ipfs-car puts Links before Data in intermediate + chunkCids.forEachIndexed { index, chunkCid -> + write(0x12) // field 2, wire type 2 (length-delimited) + val chunkSize = + if (index == chunkCids.size - 1) { + val remainingSize = fileSize % CHUNK_SIZE + if (remainingSize == 0L) CHUNK_SIZE.toLong() else remainingSize + } else { + CHUNK_SIZE.toLong() + } + val linkData = createPbLink(chunkCid, "", chunkSize) + write(encodeVarInt(linkData.size)) + write(linkData) + } + + // Field 1: Data (UnixFS metadata only) - ipfs-car puts Data after Links in intermediate + write(0x0A) // field 1, wire type 2 (length-delimited) + write(encodeVarInt(unixfsData.size)) + write(unixfsData) + }.toByteArray() + + // Create CID for the intermediate DAG-PB block + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(pbData) + val multiHash = byteArrayOf(0x12, 0x20) + hash + val cidBytes = byteArrayOf(0x01.toByte(), 0x70.toByte()) + multiHash + + return IpldBlock(cidBytes, pbData) + } + + /** + * Creates a root DAG-PB block that links to intermediate block with filename (like ipfs-car root) + */ + private fun createRootDagPbBlock( + intermediateCid: ByteArray, + fileName: String, + fileSize: Long, + ): IpldBlock { + // Create minimal UnixFS root node (directory-like) + val unixfsData = byteArrayOf(0x08, 0x01) // type = directory (1) + + // ipfs-car reports total size including intermediate block overhead (108 bytes) + val totalSize = fileSize + 108L // Add intermediate block overhead + + // Create link to intermediate block with filename + val linkData = createPbLink(intermediateCid, fileName, totalSize) + + // Create DAG-PB protobuf structure: Link first, then Data (field 2, then field 1 - ipfs-car order) + val pbData = + ByteArrayOutputStream() + .apply { + // Field 2: Link to intermediate block first + write(0x12) // field 2, wire type 2 + write(encodeVarInt(linkData.size)) + write(linkData) + + // Field 1: Data (UnixFS directory metadata) after link + write(0x0A) // field 1, wire type 2 + write(encodeVarInt(unixfsData.size)) + write(unixfsData) + }.toByteArray() + + // Create CID for the root DAG-PB block + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(pbData) + val multiHash = byteArrayOf(0x12, 0x20) + hash + val cidBytes = byteArrayOf(0x01.toByte(), 0x70.toByte()) + multiHash + + return IpldBlock(cidBytes, pbData) + } + + /** + * Creates UnixFS data for intermediate DAG-PB blocks (matches ipfs-car structure) + */ + private fun createIntermediateUnixFsData( + fileSize: Long, + chunkCount: Int, + ): ByteArray { + val output = ByteArrayOutputStream() + + // Field 1: Type (file = 2) + output.write(0x08) // field 1, varint + output.write(0x02) // file type = 2 + + // Field 3: Total file size (ipfs-car puts file size in field 3) + output.write(0x18) // field 3, varint + output.write(encodeVarInt(fileSize.toInt())) + + // Field 4: Block sizes (ipfs-car puts block sizes in field 4) + repeat(chunkCount - 1) { + output.write(0x20) // field 4, varint (block sizes) + output.write(encodeVarInt(CHUNK_SIZE)) + } + // Last chunk might be smaller + val lastChunkSize = (fileSize % CHUNK_SIZE).toInt() + if (lastChunkSize > 0) { + output.write(0x20) + output.write(encodeVarInt(lastChunkSize)) + } else { + output.write(0x20) + output.write(encodeVarInt(CHUNK_SIZE)) + } + + return output.toByteArray() + } + + /** + * Creates a protobuf link to the raw block + */ + private fun createPbLink( + cid: ByteArray, + name: String, + size: Long, + ): ByteArray { + val output = ByteArrayOutputStream() + + // Field 1: CID (Hash field) + output.write(0x0A) // field 1, wire type 2 + output.write(encodeVarInt(cid.size)) + output.write(cid) + + // Field 2: Name + output.write(0x12) // field 2, wire type 2 + val nameBytes = name.toByteArray() + output.write(encodeVarInt(nameBytes.size)) + output.write(nameBytes) + + // Field 3: Size + output.write(0x18) // field 3, wire type 0 (varint) + output.write(encodeVarInt(size.toInt())) + + return output.toByteArray() + } + + /** + * Creates CAR file with header and blocks + */ + /** + * Writes CAR data directly to a file using streaming to avoid memory issues + */ + private fun writeCarToFile( + outputFile: File, + rootCid: ByteArray, + blocks: List, + ) { + BufferedOutputStream(FileOutputStream(outputFile), 8192).use { output -> + // Create header pointing to root CID + val headerData = createCborHeader(rootCid) + + // Write header with varint length prefix + output.write(encodeVarInt(headerData.size)) + output.write(headerData) + + // Write all blocks with varint length prefixes + for (block in blocks) { + val blockSize = block.cid.size + block.data.size + output.write(encodeVarInt(blockSize)) + output.write(block.cid) + output.write(block.data) + } + + output.flush() + } + } + + /** + * Calculates CAR CID from a file by streaming (avoids loading entire file into memory) + */ + private fun createCarCidFromFile(carFile: File): String { + val digest = MessageDigest.getInstance("SHA-256") + + // Stream the file in chunks to calculate hash + FileInputStream(carFile).use { inputStream -> + val buffer = ByteArray(8192) + var bytesRead: Int + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + + val hash = digest.digest() + // Create multihash: 0x12 (SHA-256) + 0x20 (32 bytes) + hash + val multiHash = byteArrayOf(0x12, 0x20) + hash + + // Create CID v1: version(1) + codec(CAR multicodec 0x0202) + multihash + // CAR multicodec 0x0202 (514) encodes as varint [0x82, 0x04] + val carCodecVarint = encodeVarInt(0x0202) + val cidBytes = byteArrayOf(0x01.toByte()) + carCodecVarint + multiHash + + return cidBytesToString(cidBytes) + } + + /** + * Creates CBOR header with empty roots array and version (matches ipfs-car format) + */ + private fun createCborHeader(rootCid: ByteArray): ByteArray { + val output = ByteArrayOutputStream() + + // a2 = CBOR map with 2 items + output.write(0xA2) + + // 65 + "roots" = text string "roots" (length 5) + output.write(0x65) + output.write("roots".toByteArray()) + + // 80 = CBOR empty array (ipfs-car uses empty roots) + // output.write(0x80) + + // Roots array with 1 element + output.write(0x81) // CBOR array of length 1 + output.write(0xD8) // CBOR tag + output.write(0x2A) // Tag 42 for CID + + // Byte string with (1 + rootCid.size) length + output.write(0x58) + output.write(rootCid.size + 1) // include leading 0x00 + + // Write the 0x00 prefix + output.write(0x00) + + // Write the actual CID bytes + output.write(rootCid) + + // 67 + "version" = text string "version" (length 7) + output.write(0x67) + output.write("version".toByteArray()) + + // 01 = integer 1 + output.write(0x01) + + return output.toByteArray() + } + + /** + * Encodes integers as LEB128 varint + */ + private fun encodeVarInt(value: Int): ByteArray { + val result = mutableListOf() + var v = value + while (v >= 0x80) { + result.add(((v and 0x7F) or 0x80).toByte()) + v = v ushr 7 + } + result.add(v.toByte()) + return result.toByteArray() + } + + /** + * Convert CID bytes to proper CID string format + */ + private fun cidBytesToString(cidBytes: ByteArray): String = "b${encodeBase32(cidBytes)}" + + + /** + * Encode data as base32 string (RFC 4648) + */ + private fun encodeBase32(data: ByteArray): String { + val alphabet = "abcdefghijklmnopqrstuvwxyz234567" + val result = StringBuilder() + + var buffer = 0L + var bitsLeft = 0 + + for (byte in data) { + buffer = (buffer shl 8) or (byte.toInt() and 0xFF).toLong() + bitsLeft += 8 + + while (bitsLeft >= 5) { + result.append(alphabet[((buffer shr (bitsLeft - 5)) and 0x1F).toInt()]) + bitsLeft -= 5 + } + } + + if (bitsLeft > 0) { + result.append(alphabet[((buffer shl (5 - bitsLeft)) and 0x1F).toInt()]) + } + + return result.toString() + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/DidManager.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/DidManager.kt new file mode 100644 index 000000000..797edac9e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/DidManager.kt @@ -0,0 +1,45 @@ +package net.opendasharchive.openarchive.services.storacha.util + +import android.content.Context +import androidx.core.content.edit +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.Security + +class DidManager( + context: Context, +) { + private val legacyPrefs = context.getSharedPreferences("storacha_prefs", Context.MODE_PRIVATE) + private val key = "device_did" + private val secureStorage = SecureStorage(context, "storacha_did_keys") + + fun getOrCreateDid(): String { + // First check if we have a DID in secure storage + val existingDid = secureStorage.getDid() + if (existingDid != null) { + return existingDid + } + + // Check if we have one in old prefs (for migration) + val legacyDid = legacyPrefs.getString(key, null) + if (legacyDid != null) { + // For legacy DIDs without stored keys, generate new ones + // This will replace the old DID with a new one that has proper keys + legacyPrefs.edit { remove(key) } + } + + // Generate new DID with keys + return generateNewDid() + } + + private fun generateNewDid(): String { + // Ensure BouncyCastle is registered + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(BouncyCastleProvider()) + } + + // Generate and store new key pair securely + return secureStorage.generateAndStoreKeyPair() + } + + fun hasDid(): Boolean = secureStorage.hasKeys() +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/Ed25519Utils.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/Ed25519Utils.kt new file mode 100644 index 000000000..af8d7f5d5 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/Ed25519Utils.kt @@ -0,0 +1,206 @@ +package net.opendasharchive.openarchive.services.storacha.util + +import android.util.Base64 +import org.bouncycastle.crypto.AsymmetricCipherKeyPair +import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator +import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import java.security.SecureRandom + +/** + * Utility class for Ed25519 cryptographic operations including DID generation and signature creation + */ +object Ed25519Utils { + /** + * Generates a new Ed25519 key pair + * @return AsymmetricCipherKeyPair containing private and public keys + */ + fun generateKeyPair(): AsymmetricCipherKeyPair { + val keyPairGenerator = Ed25519KeyPairGenerator() + keyPairGenerator.init(Ed25519KeyGenerationParameters(SecureRandom())) + return keyPairGenerator.generateKeyPair() + } + + /** + * Creates a DID from an Ed25519 public key + * @param publicKey The Ed25519 public key + * @return DID string in the format "did:key:z6Mk..." + */ + fun createDidFromPublicKey(publicKey: Ed25519PublicKeyParameters): String { + val publicKeyBytes = publicKey.encoded + + // DID key multicodec prefix for Ed25519 (0xed01) + val multicodecPrefix = byteArrayOf(0xed.toByte(), 0x01) + val multicodecKey = multicodecPrefix + publicKeyBytes + + // Base58 encode (using a simple implementation for z-base58) + val base58Encoded = encodeBase58(multicodecKey) + + return "did:key:z$base58Encoded" + } + + /** + * Signs a challenge string with the provided private key + * @param challenge The challenge string to sign + * @param privateKey The Ed25519 private key + * @return Base64-encoded signature + */ + fun signChallenge( + challenge: String, + privateKey: Ed25519PrivateKeyParameters, + ): String { + val signer = Ed25519Signer() + signer.init(true, privateKey) + + val challengeBytes = challenge.toByteArray(Charsets.UTF_8) + signer.update(challengeBytes, 0, challengeBytes.size) + + val signature = signer.generateSignature() + return Base64.encodeToString(signature, Base64.NO_WRAP) + } + + /** + * Verifies a signature against a challenge and public key + * @param challenge The original challenge string + * @param signature Base64-encoded signature + * @param publicKey The Ed25519 public key + * @return true if signature is valid, false otherwise + */ + fun verifySignature( + challenge: String, + signature: String, + publicKey: Ed25519PublicKeyParameters, + ): Boolean = + try { + val signer = Ed25519Signer() + signer.init(false, publicKey) + + val challengeBytes = challenge.toByteArray(Charsets.UTF_8) + signer.update(challengeBytes, 0, challengeBytes.size) + + val signatureBytes = Base64.decode(signature, Base64.NO_WRAP) + signer.verifySignature(signatureBytes) + } catch (_: Exception) { + false + } + + /** + * Validates if a string is a valid DID:key format + * @param did The DID string to validate + * @return true if valid DID format, false otherwise + */ + fun isValidDid(did: String): Boolean { + return try { + // Check basic format + if (!did.startsWith("did:key:z")) return false + + // Must have content after "did:key:z" + if (did.length <= 9) return false + + val base58Part = did.substring(9) + + // Base58 part should not be empty + if (base58Part.isEmpty()) return false + + // Try to decode and validate the structure + val multicodecKey = decodeBase58(base58Part) + + // Check multicodec prefix for Ed25519 (0xed01) and minimum length + if (multicodecKey.size < 34) return false + if (multicodecKey[0] != 0xed.toByte() || multicodecKey[1] != 0x01.toByte()) return false + + // If we can extract the public key, it's valid + val publicKeyBytes = multicodecKey.sliceArray(2..33) + Ed25519PublicKeyParameters(publicKeyBytes, 0) + + true + } catch (_: Exception) { + false + } + } + + /** + * Extracts public key from a DID:key string + * @param did The DID string in format "did:key:z6Mk..." + * @return Ed25519PublicKeyParameters or null if invalid + */ + fun extractPublicKeyFromDid(did: String): Ed25519PublicKeyParameters? { + return try { + if (!did.startsWith("did:key:z")) return null + + val base58Part = did.substring(9) // Remove "did:key:z" + val multicodecKey = decodeBase58(base58Part) + + // Check multicodec prefix for Ed25519 (0xed01) + if (multicodecKey.size < 34 || multicodecKey[0] != 0xed.toByte() || multicodecKey[1] != 0x01.toByte()) { + return null + } + + val publicKeyBytes = multicodecKey.sliceArray(2..33) + Ed25519PublicKeyParameters(publicKeyBytes, 0) + } catch (_: Exception) { + null + } + } + + /** + * Simple Base58 encoding implementation + */ + private fun encodeBase58(input: ByteArray): String { + val alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + if (input.isEmpty()) return "" + + // Convert to base 58 + var num = java.math.BigInteger(1, input) + val base = java.math.BigInteger.valueOf(58) + val result = StringBuilder() + + while (num > java.math.BigInteger.ZERO) { + val remainder = num.remainder(base) + num = num.divide(base) + result.insert(0, alphabet[remainder.toInt()]) + } + + // Add leading zeros + for (b in input) { + if (b.toInt() == 0) { + result.insert(0, alphabet[0]) + } else { + break + } + } + + return result.toString() + } + + /** + * Simple Base58 decoding implementation + */ + private fun decodeBase58(input: String): ByteArray { + val alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + val base = java.math.BigInteger.valueOf(58) + var num = java.math.BigInteger.ZERO + + for (char in input) { + val charIndex = alphabet.indexOf(char) + if (charIndex < 0) throw IllegalArgumentException("Invalid character in Base58 string") + num = num.multiply(base).add(java.math.BigInteger.valueOf(charIndex.toLong())) + } + + val bytes = num.toByteArray() + + // Remove leading zero byte if present + val result = + if (bytes.isNotEmpty() && bytes[0].toInt() == 0) { + bytes.sliceArray(1 until bytes.size) + } else { + bytes + } + + // Add leading zeros for '1' characters + val leadingOnes = input.takeWhile { it == alphabet[0] }.length + return ByteArray(leadingOnes) + result + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/FileMetadataFetcher.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/FileMetadataFetcher.kt new file mode 100644 index 000000000..87ca214de --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/FileMetadataFetcher.kt @@ -0,0 +1,93 @@ +package net.opendasharchive.openarchive.services.storacha.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.R +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.regex.Pattern + +data class FileMetadata( + val fileName: String, + val fileSize: String, + val fileType: FileType, + val directUrl: String, +) + +enum class FileType( + val iconRes: Int, +) { + IMAGE(R.drawable.ic_image), + VIDEO(R.drawable.ic_video), + AUDIO(R.drawable.ic_music), + PDF(R.drawable.ic_pdf), + DOCUMENT(R.drawable.ic_doc), + ZIP(R.drawable.ic_zip), + UNKNOWN(R.drawable.ic_unknown), +} + +class FileMetadataFetcher( + private val client: OkHttpClient, +) { + // Pre-compile patterns for better performance + private val linkPattern = Pattern.compile("([^<]+)") + private val sizePattern = Pattern.compile("([0-9]+(?:\\.[0-9]+)?\\s*[KMGT]?B)", Pattern.CASE_INSENSITIVE) + private val base58Chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toSet() + + suspend fun fetchFileMetadata(gatewayUrl: String): FileMetadata? = withContext(Dispatchers.IO) { + try { + val request = Request.Builder().url(gatewayUrl).build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@withContext null + response.body?.string()?.let { parseFileMetadata(it, gatewayUrl) } + } + } catch (_: Exception) { + null + } + } + + private fun parseFileMetadata(html: String, baseUrl: String): FileMetadata? { + val matcher = linkPattern.matcher(html) + + while (matcher.find()) { + val href = matcher.group(1)?.trim() ?: continue + val fileName = matcher.group(2)?.trim() ?: continue + + if (href == "../" || fileName == ".." || href.isEmpty() || fileName.isEmpty()) continue + if (isIpfsHash(fileName)) continue + + val directUrl = "${baseUrl.trimEnd('/')}/$fileName" + val fileType = determineFileType(fileName) + + // Extract file size from context around the link + val start = maxOf(0, matcher.start() - 100) + val end = minOf(html.length, matcher.end() + 200) + val context = html.substring(start, end) + val sizeMatcher = sizePattern.matcher(context) + val fileSize = if (sizeMatcher.find()) sizeMatcher.group(1) ?: "Unknown size" else "Unknown size" + + return FileMetadata(fileName, fileSize, fileType, directUrl) + } + return null + } + + private fun isIpfsHash(text: String): Boolean = when { + text.startsWith("Qm") && text.length == 46 && text.all { it in base58Chars } -> true + text.startsWith("ba") && text.length in 52..64 && '.' !in text -> true + text.length > 100 && '.' !in text -> true + else -> false + } + + private fun determineFileType(fileName: String): FileType { + val extension = fileName.substringAfterLast('.', "").lowercase() + return when (extension) { + "jpg", "jpeg", "png", "gif", "bmp", "webp", "avif", "heic", "heif" -> FileType.IMAGE + "mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v" -> FileType.VIDEO + "mp3", "wav", "flac", "aac", "ogg", "m4a", "wma" -> FileType.AUDIO + "pdf" -> FileType.PDF + "zip" -> FileType.ZIP + "doc", "docx", "txt", "rtf", "odt", "xls", "xlsx", "ppt", "pptx", "csv" -> FileType.DOCUMENT + else -> FileType.UNKNOWN + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/SecureStorage.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/SecureStorage.kt new file mode 100644 index 000000000..335c7f25a --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/SecureStorage.kt @@ -0,0 +1,227 @@ +package net.opendasharchive.openarchive.services.storacha.util + +import android.content.Context +import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.core.content.edit +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Modern secure storage using Android Keystore directly + * Replaces deprecated EncryptedSharedPreferences + */ +class SecureStorage( + private val context: Context, + private val alias: String = "StorachaSecureStorage", +) { + private val keyStore: KeyStore by lazy { + KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + } + + private val sharedPreferences: SharedPreferences by lazy { + context.getSharedPreferences("${alias}_prefs", Context.MODE_PRIVATE) + } + + companion object { + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val GCM_IV_LENGTH = 12 + private const val GCM_TAG_LENGTH = 16 + } + + init { + generateKeyIfNeeded() + } + + private fun generateKeyIfNeeded() { + if (!keyStore.containsAlias(alias)) { + val keyGenerator = + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + val keyGenParameterSpec = + KeyGenParameterSpec + .Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(false) + .setRandomizedEncryptionRequired(true) + .build() + + keyGenerator.init(keyGenParameterSpec) + keyGenerator.generateKey() + } + } + + private fun getSecretKey(): SecretKey = keyStore.getKey(alias, null) as SecretKey + + private fun encrypt(data: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getSecretKey()) + + val iv = cipher.iv + val encryptedData = cipher.doFinal(data.toByteArray(Charsets.UTF_8)) + + // Combine IV and encrypted data + val combined = iv + encryptedData + return Base64.encodeToString(combined, Base64.DEFAULT) + } + + private fun decrypt(encryptedData: String): String { + val combined = Base64.decode(encryptedData, Base64.DEFAULT) + + // Extract IV and encrypted data + val iv = combined.sliceArray(0..GCM_IV_LENGTH - 1) + val encrypted = combined.sliceArray(GCM_IV_LENGTH until combined.size) + + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(GCM_TAG_LENGTH * 8, iv) + cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) + + val decrypted = cipher.doFinal(encrypted) + return String(decrypted, Charsets.UTF_8) + } + + fun putString( + key: String, + value: String?, + ) { + sharedPreferences.edit { + if (value != null) { + putString(key, encrypt(value)) + } else { + remove(key) + } + } + } + + fun getString( + key: String, + defaultValue: String? = null, + ): String? { + val encryptedValue = sharedPreferences.getString(key, null) + return if (encryptedValue != null) { + try { + decrypt(encryptedValue) + } catch (_: Exception) { + // Handle decryption failure gracefully + defaultValue + } + } else { + defaultValue + } + } + + fun putBoolean( + key: String, + value: Boolean, + ) { + putString(key, value.toString()) + } + + fun getBoolean( + key: String, + defaultValue: Boolean = false, + ): Boolean = getString(key)?.toBooleanStrictOrNull() ?: defaultValue + + fun putInt( + key: String, + value: Int, + ) { + putString(key, value.toString()) + } + + fun getInt( + key: String, + defaultValue: Int = 0, + ): Int = getString(key)?.toIntOrNull() ?: defaultValue + + fun remove(key: String) { + sharedPreferences.edit { + remove(key) + } + } + + fun clear() { + sharedPreferences.edit { + clear() + } + } + + fun contains(key: String): Boolean = sharedPreferences.contains(key) + + // === Ed25519 Key Storage Extensions === + + /** + * Stores Ed25519 key pair securely + */ + fun storeKeyPair( + privateKey: Ed25519PrivateKeyParameters, + publicKey: Ed25519PublicKeyParameters, + did: String, + ) { + val privateKeyBytes = privateKey.encoded + val publicKeyBytes = publicKey.encoded + + putString("ed25519_private_key", Base64.encodeToString(privateKeyBytes, Base64.NO_WRAP)) + putString("ed25519_public_key", Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)) + putString("user_did", did) + } + + /** + * Retrieves the stored Ed25519 private key + */ + fun getPrivateKey(): Ed25519PrivateKeyParameters? { + val privateKeyString = getString("ed25519_private_key") ?: return null + val privateKeyBytes = Base64.decode(privateKeyString, Base64.NO_WRAP) + return Ed25519PrivateKeyParameters(privateKeyBytes, 0) + } + + /** + * Retrieves the stored Ed25519 public key + */ + fun getPublicKey(): Ed25519PublicKeyParameters? { + val publicKeyString = getString("ed25519_public_key") ?: return null + val publicKeyBytes = Base64.decode(publicKeyString, Base64.NO_WRAP) + return Ed25519PublicKeyParameters(publicKeyBytes, 0) + } + + /** + * Retrieves the stored DID + */ + fun getDid(): String? = getString("user_did") + + /** + * Checks if Ed25519 keys are stored + */ + fun hasKeys(): Boolean = getPrivateKey() != null && getPublicKey() != null && getDid() != null + + /** + * Clears all stored Ed25519 keys + */ + fun clearKeys() { + remove("ed25519_private_key") + remove("ed25519_public_key") + remove("user_did") + } + + /** + * Generates and stores a new Ed25519 key pair + */ + fun generateAndStoreKeyPair(): String { + val keyPair = Ed25519Utils.generateKeyPair() + val privateKey = keyPair.private as Ed25519PrivateKeyParameters + val publicKey = keyPair.public as Ed25519PublicKeyParameters + val did = Ed25519Utils.createDidFromPublicKey(publicKey) + + storeKeyPair(privateKey, publicKey, did) + return did + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/SessionManager.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/SessionManager.kt new file mode 100644 index 000000000..999d46891 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/SessionManager.kt @@ -0,0 +1,201 @@ +package net.opendasharchive.openarchive.services.storacha.util + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.opendasharchive.openarchive.services.storacha.model.LoginRequest +import net.opendasharchive.openarchive.services.storacha.model.VerifyRequest +import net.opendasharchive.openarchive.services.storacha.service.StorachaApiService +import timber.log.Timber + +class SessionManager( + private val apiService: StorachaApiService, + private val accountManager: StorachaAccountManager, + private val secureStorage: SecureStorage, +) { + companion object { + private const val TAG = "SessionManager" + } + + // Mutex to prevent concurrent refresh attempts + private val refreshMutex = Mutex() + + // Track if a refresh is currently in progress + private var isRefreshing = false + + /** + * Validation result containing session status details + */ + data class ValidationResult( + val isValid: Boolean, + val isVerified: Boolean, + val needsEmailVerification: Boolean = false, + ) + + /** + * Validates the current session by calling GET /auth/session. + * Returns a ValidationResult with detailed session status. + */ + suspend fun validateSessionDetailed(): ValidationResult { + val currentAccount = accountManager.getCurrentAccount() + if (currentAccount == null) { + Timber.tag(TAG).d("No current account found") + return ValidationResult(isValid = false, isVerified = false) + } + + return try { + val response = apiService.validateSession(currentAccount.sessionId) + val isVerified = response.verified == 1 + val needsEmailVerification = response.valid && !isVerified + + Timber + .tag(TAG) + .d("Session validation result: valid=${response.valid}, verified=${response.verified}") + + ValidationResult( + isValid = response.valid && isVerified, + isVerified = isVerified, + needsEmailVerification = needsEmailVerification, + ) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error validating session") + ValidationResult(isValid = false, isVerified = false) + } + } + + /** + * Validates the current session by calling GET /auth/session. + * Returns true if the session is valid and verified, false otherwise. + */ + suspend fun validateSession(): Boolean = validateSessionDetailed().isValid + + /** + * Attempts to refresh the session using the stored DID and challenge-response flow. + * Returns Result.success with new session ID on success, or Result.failure on error. + * + * This method uses a mutex to prevent concurrent refresh attempts. + */ + suspend fun refreshSession(): Result = + refreshMutex.withLock { + if (isRefreshing) { + Timber.tag(TAG).d("Refresh already in progress, skipping") + return Result.failure(Exception("Refresh already in progress")) + } + + isRefreshing = true + try { + performRefresh() + } finally { + isRefreshing = false + } + } + + /** + * Internal method to perform the actual session refresh. + */ + private suspend fun performRefresh(): Result { + val currentAccount = accountManager.getCurrentAccount() + if (currentAccount == null) { + Timber.tag(TAG).e("No current account found for refresh") + return Result.failure(Exception("No current account")) + } + + val did = currentAccount.did + val email = currentAccount.email + + if (did.isNullOrBlank()) { + Timber.tag(TAG).e("No DID found in current account") + return Result.failure(Exception("No DID found")) + } + + return try { + // Step 1: Call login to get challenge + Timber.tag(TAG).d("Calling login to get challenge for refresh") + val loginResponse = apiService.login(LoginRequest(email, did)) + + val challenge = loginResponse.challenge + val challengeId = loginResponse.challengeId + + if (challenge.isNullOrBlank() || challengeId.isNullOrBlank()) { + Timber.tag(TAG).e("No challenge received from login") + return Result.failure(Exception("No challenge received")) + } + + // Step 2: Sign the challenge + Timber.tag(TAG).d("Signing challenge for verification") + val signature = signChallenge(challenge) + + // Step 3: Verify the signature + Timber.tag(TAG).d("Verifying signature") + val verifyResponse = + apiService.verify( + VerifyRequest( + did = did, + challengeId = challengeId, + signature = signature, + sessionId = loginResponse.sessionId, + email = email, + ), + ) + + val newSessionId = verifyResponse.sessionId + Timber.tag(TAG).d("Session refresh successful, new session ID obtained") + + // Step 4: Update the account with new session ID + updateSessionId(email, newSessionId) + + Result.success(newSessionId) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error refreshing session") + Result.failure(e) + } + } + + /** + * Signs a challenge using the stored Ed25519 private key. + */ + private fun signChallenge(challenge: String): String { + val privateKey = + secureStorage.getPrivateKey() + ?: throw IllegalStateException("No private key found in secure storage") + return Ed25519Utils.signChallenge(challenge, privateKey) + } + + /** + * Updates the session ID for the given account email. + */ + private fun updateSessionId( + email: String, + newSessionId: String, + ) { + val account = accountManager.getAccount(email) + + if (account != null) { + // Use addAccount which will update existing account + accountManager.addAccount( + email = email, + sessionId = newSessionId, + isVerified = account.isVerified, + did = account.did, + ) + + Timber.tag(TAG).d("Session ID updated for account: $email") + } else { + Timber.tag(TAG).e("Account not found for session ID update: $email") + } + } + + /** + * Removes the current account from the accounts list (used when session expires and cannot be refreshed). + * This is different from just clearing the current account pointer - it actually deletes the account. + */ + fun removeCurrentAccount() { + val currentAccount = accountManager.getCurrentAccount() + if (currentAccount != null) { + accountManager.removeAccount(currentAccount.email) + Timber.tag(TAG).d("Invalid account removed: ${currentAccount.email}") + } else { + accountManager.clearCurrentAccount() + Timber.tag(TAG).d("Current account pointer cleared") + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/StorachaAccountManager.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/StorachaAccountManager.kt new file mode 100644 index 000000000..58d7566e1 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/StorachaAccountManager.kt @@ -0,0 +1,132 @@ +package net.opendasharchive.openarchive.services.storacha.util + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import net.opendasharchive.openarchive.services.storacha.model.StorachaAccount + +/** + * Manages multiple Storacha account sessions using modern Android Keystore + */ +class StorachaAccountManager( + context: Context, +) { + private val secureStorage = SecureStorage(context, "storacha_accounts") + private val gson = Gson() + + companion object { + private const val ACCOUNTS_KEY = "logged_in_accounts" + private const val CURRENT_ACCOUNT_KEY = "current_account_email" + } + + /** + * Add or update an account after successful login + */ + fun addAccount( + email: String, + sessionId: String, + isVerified: Boolean = false, + did: String? = null, + ) { + val accounts = getLoggedInAccounts().toMutableList() + val existingIndex = accounts.indexOfFirst { it.email == email } + + val account = StorachaAccount(email, sessionId, isVerified, did) + + if (existingIndex >= 0) { + accounts[existingIndex] = account + } else { + accounts.add(account) + } + + saveAccounts(accounts) + setCurrentAccount(email) + } + + /** + * Remove an account (logout) + */ + fun removeAccount(email: String) { + val accounts = getLoggedInAccounts().toMutableList() + accounts.removeAll { it.email == email } + saveAccounts(accounts) + + // If we removed the current account, clear current account + if (getCurrentAccountEmail() == email) { + clearCurrentAccount() + } + } + + /** + * Get all logged-in accounts + */ + fun getLoggedInAccounts(): List { + val accountsJson = secureStorage.getString(ACCOUNTS_KEY) ?: return emptyList() + val type = object : TypeToken>() {}.type + return gson.fromJson(accountsJson, type) ?: emptyList() + } + + /** + * Check if any accounts are logged in + */ + fun hasLoggedInAccounts(): Boolean = getLoggedInAccounts().isNotEmpty() + + /** + * Get account by email + */ + fun getAccount(email: String): StorachaAccount? = getLoggedInAccounts().find { it.email == email } + + /** + * Get current account email + */ + fun getCurrentAccountEmail(): String? = secureStorage.getString(CURRENT_ACCOUNT_KEY) + + /** + * Get current account + */ + fun getCurrentAccount(): StorachaAccount? { + val email = getCurrentAccountEmail() ?: return null + return getAccount(email) + } + + /** + * Set current account + */ + fun setCurrentAccount(email: String) { + secureStorage.putString(CURRENT_ACCOUNT_KEY, email) + } + + /** + * Clear current account + */ + fun clearCurrentAccount() { + secureStorage.remove(CURRENT_ACCOUNT_KEY) + } + + /** + * Update account verification status + */ + fun updateAccountVerification( + email: String, + isVerified: Boolean, + ) { + val accounts = getLoggedInAccounts().toMutableList() + val existingIndex = accounts.indexOfFirst { it.email == email } + + if (existingIndex >= 0) { + val existingAccount = accounts[existingIndex] + accounts[existingIndex] = existingAccount.copy(isVerified = isVerified) + saveAccounts(accounts) + } + } + + /** + * Get current account's verification status + */ + fun isCurrentAccountVerified(): Boolean = getCurrentAccount()?.isVerified == true + + private fun saveAccounts(accounts: List) { + val accountsJson = gson.toJson(accounts) + secureStorage.putString(ACCOUNTS_KEY, accountsJson) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/StorachaHelper.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/StorachaHelper.kt new file mode 100644 index 000000000..26a88c553 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/util/StorachaHelper.kt @@ -0,0 +1,59 @@ +package net.opendasharchive.openarchive.services.storacha.util + +import android.content.Context +import androidx.core.content.edit + +/** + * Helper class for common Storacha-related checks and operations + */ +object StorachaHelper { + private const val PREFS_NAME = "storacha_helper_prefs" + private const val KEY_SPACE_COUNT = "space_count" + + /** + * Checks if the user should have access to Storacha features. + * Returns true if either: + * 1. User has logged-in accounts, OR + * 2. User has access to one or more spaces (space count > 0) + * + * @param context The application context + * @return true if user should have access, false otherwise + */ + fun shouldEnableStorachaAccess(context: Context): Boolean { + val accountManager = StorachaAccountManager(context) + + // Check if user has logged-in accounts + if (accountManager.hasLoggedInAccounts()) { + return true + } + + // Check if user has access to spaces + val spaceCount = getSpaceCount(context) + return spaceCount > 0 + } + + /** + * Updates the stored space count + * + * @param context The application context + * @param count The number of spaces the user has access to + */ + fun updateSpaceCount( + context: Context, + count: Int, + ) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putInt(KEY_SPACE_COUNT, count) } + } + + /** + * Gets the stored space count + * + * @param context The application context + * @return The number of spaces the user has access to (default: 0) + */ + fun getSpaceCount(context: Context): Int { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getInt(KEY_SPACE_COUNT, 0) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaAccountDetailsViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaAccountDetailsViewModel.kt new file mode 100644 index 000000000..66f2f7a06 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaAccountDetailsViewModel.kt @@ -0,0 +1,101 @@ +package net.opendasharchive.openarchive.services.storacha.viewModel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.services.storacha.model.AccountUsageResponse +import net.opendasharchive.openarchive.services.storacha.model.PlanInfo +import net.opendasharchive.openarchive.services.storacha.service.StorachaApiService +import retrofit2.HttpException + +class StorachaAccountDetailsViewModel( + application: Application, + private val apiService: StorachaApiService, +) : AndroidViewModel(application) { + private val _accountUsage = MutableLiveData>() + val accountUsage: LiveData> = _accountUsage + + private val _isLoading = MutableLiveData() + val isLoading: LiveData = _isLoading + + private val _logoutResult = MutableLiveData>() + val logoutResult: LiveData> = _logoutResult + + private val _sessionExpired = MutableLiveData() + val sessionExpired: LiveData = _sessionExpired + + fun loadAccountUsage(sessionId: String) { + _isLoading.value = true + viewModelScope.launch { + try { + val response = apiService.getAccountUsage(sessionId) + _accountUsage.value = Result.success(response) + } catch (e: HttpException) { + if (e.code() == 401) { + _sessionExpired.value = true + } + _accountUsage.value = Result.failure(e) + } catch (e: Exception) { + _accountUsage.value = Result.failure(e) + } finally { + _isLoading.value = false + } + } + } + + fun getPlanInfo(accountUsage: AccountUsageResponse): PlanInfo { + return PlanInfo.fromPlanProduct(accountUsage.planProduct) + } + + fun getUsagePercentage(totalBytes: Long, planInfo: PlanInfo): Int = + if (planInfo.storageLimit > 0) { + ((totalBytes.toDouble() / planInfo.storageLimit.toDouble()) * 100).toInt().coerceIn(0, 100) + } else { + 0 + } + + fun getUsagePercentage( + totalBytes: Long, + maxBytes: Long = 2L * 1024 * 1024 * 1024 * 1024, + ): Int = + if (maxBytes > 0) { + ((totalBytes.toDouble() / maxBytes.toDouble()) * 100).toInt().coerceIn(0, 100) + } else { + 0 + } + + 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 String.format("%.2f %s", size, units[unitIndex]) + } + + fun formatUsageText(usedBytes: Long): String = "Used: ${formatBytes(usedBytes)}" + + fun formatUsageText(usedBytes: Long, planInfo: PlanInfo): String { + val used = formatBytes(usedBytes) + val total = formatBytes(planInfo.storageLimit) + return "$used of $total used" + } + + fun logout(sessionId: String) { + viewModelScope.launch { + try { + apiService.logout(sessionId) + _logoutResult.value = Result.success(Unit) + } catch (e: Exception) { + _logoutResult.value = Result.failure(e) + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaBrowseSpacesViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaBrowseSpacesViewModel.kt new file mode 100644 index 000000000..6ae367ce5 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaBrowseSpacesViewModel.kt @@ -0,0 +1,49 @@ +package net.opendasharchive.openarchive.services.storacha.viewModel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.services.storacha.model.SpaceInfo +import net.opendasharchive.openarchive.services.storacha.service.StorachaApiService +import retrofit2.HttpException + +class StorachaBrowseSpacesViewModel( + private val apiService: StorachaApiService, +) : ViewModel() { + private val _spaces = MutableLiveData>() + val spaces: LiveData> get() = _spaces + + private val _loading = MutableLiveData() + val loading: LiveData get() = _loading + + private val _sessionExpired = MutableLiveData() + val sessionExpired: LiveData get() = _sessionExpired + + fun clearSessionExpired() { + _sessionExpired.value = false + } + + fun loadSpaces( + userDid: String, + sessionId: String, + ) { + _loading.value = true + viewModelScope.launch { + try { + val spaceInfos = apiService.listSpaces(userDid, sessionId) + _spaces.value = spaceInfos + } catch (e: HttpException) { + if (e.code() == 401) { + _sessionExpired.value = true + } + _spaces.value = emptyList() + } catch (e: Exception) { + _spaces.value = emptyList() + } finally { + _loading.value = false + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaDIDAccessViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaDIDAccessViewModel.kt new file mode 100644 index 000000000..5103c7d98 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaDIDAccessViewModel.kt @@ -0,0 +1,60 @@ +package net.opendasharchive.openarchive.services.storacha.viewModel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.services.storacha.model.DelegationRequest +import net.opendasharchive.openarchive.services.storacha.service.StorachaApiService +import retrofit2.HttpException + +class StorachaDIDAccessViewModel( + private val apiService: StorachaApiService, +) : ViewModel() { + private val _loading = MutableLiveData() + val loading: LiveData get() = _loading + + private val _success = MutableLiveData() + val success: LiveData get() = _success + + private val _error = MutableLiveData() + val error: LiveData get() = _error + + private val _sessionExpired = MutableLiveData() + val sessionExpired: LiveData = _sessionExpired + + fun createDelegation( + sessionId: String, + userDid: String, + spaceDid: String, + expiresIn: Int = 24, + ) { + _loading.value = true + _error.value = null + _success.value = false + + viewModelScope.launch { + try { + val request = + DelegationRequest( + userDid = userDid, + spaceDid = spaceDid, + expiresIn = expiresIn, + ) + + apiService.createDelegation(sessionId, request) + _success.value = true + } catch (e: HttpException) { + if (e.code() == 401) { + _sessionExpired.value = true + } + _error.value = e.message ?: "Failed to create delegation" + } catch (e: Exception) { + _error.value = e.message ?: "Failed to create delegation" + } finally { + _loading.value = false + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaEmailVerificationSentViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaEmailVerificationSentViewModel.kt new file mode 100644 index 000000000..6e6b24bce --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaEmailVerificationSentViewModel.kt @@ -0,0 +1,97 @@ +package net.opendasharchive.openarchive.services.storacha.viewModel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.services.storacha.model.SessionValidationResponse +import net.opendasharchive.openarchive.services.storacha.service.StorachaApiService +import net.opendasharchive.openarchive.services.storacha.util.StorachaAccountManager + +class StorachaEmailVerificationSentViewModel( + application: Application, + private val apiService: StorachaApiService, + private val sessionId: String, +) : AndroidViewModel(application) { + private val _navigateNext = MutableLiveData() + val navigateNext: LiveData = _navigateNext + + private val _showTimeoutDialog = MutableLiveData() + val showTimeoutDialog: LiveData = _showTimeoutDialog + + private val accountManager = StorachaAccountManager(application) + private var pollingJob: Job? = null + private var attemptCount = 0 + private val maxAttempts = 30 + + init { + // Check if already verified before starting to poll + if (!accountManager.isCurrentAccountVerified()) { + startPollingVerificationStatus() + } else { + // Already verified, navigate immediately + _navigateNext.postValue(Unit) + } + } + + private fun startPollingVerificationStatus() { + pollingJob = + viewModelScope.launch { + while (attemptCount < maxAttempts) { + try { + val response: SessionValidationResponse = apiService.validateSession(sessionId) + if (response.valid && response.verified == 1) { + // Update account verification status in secure storage + val currentAccount = accountManager.getCurrentAccount() + currentAccount?.email?.let { email -> + accountManager.updateAccountVerification(email, true) + } + _navigateNext.postValue(Unit) + return@launch + } + } catch (_: Exception) { + // Optional: log error + // Continue polling even on error + } + + attemptCount++ + if (attemptCount >= maxAttempts) { + _showTimeoutDialog.postValue(Unit) + break + } + + delay(2000) + } + } + } + + fun resumePolling() { + if (pollingJob?.isActive != true && !accountManager.isCurrentAccountVerified()) { + startPollingVerificationStatus() + } + } + + fun pausePolling() { + pollingJob?.cancel() + } + + fun tryAgain() { + // Reset attempt counter and restart polling + attemptCount = 0 + pausePolling() + startPollingVerificationStatus() + } + + fun resetAttemptCounter() { + attemptCount = 0 + } + + override fun onCleared() { + super.onCleared() + pollingJob?.cancel() + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaLoginViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaLoginViewModel.kt new file mode 100644 index 000000000..a52b2bc31 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaLoginViewModel.kt @@ -0,0 +1,119 @@ +package net.opendasharchive.openarchive.services.storacha.viewModel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.services.storacha.model.LoginRequest +import net.opendasharchive.openarchive.services.storacha.model.LoginResponse +import net.opendasharchive.openarchive.services.storacha.model.VerifyRequest +import net.opendasharchive.openarchive.services.storacha.model.VerifyResponse +import net.opendasharchive.openarchive.services.storacha.service.StorachaApiService +import net.opendasharchive.openarchive.services.storacha.util.Ed25519Utils +import net.opendasharchive.openarchive.services.storacha.util.SecureStorage + +class StorachaLoginViewModel( + application: Application, + private val apiService: StorachaApiService, +) : AndroidViewModel(application) { + private val _loginResult = MutableLiveData>() + val loginResult: LiveData> = _loginResult + + private val _isLoading = MutableLiveData() + val isLoading: LiveData = _isLoading + + private val verifyResult = MutableLiveData>() + private var currentSessionId: String? = null + private var currentChallenge: String? = null + private var currentChallengeId: String? = null + private val secureStorage = SecureStorage(application, "storacha_did_keys") + + fun login( + email: String, + did: String, + ) { + // Prevent duplicate requests + if (_isLoading.value == true) { + return + } + + _isLoading.value = true + val request = LoginRequest(email = email, did = did) + viewModelScope.launch { + try { + val response = apiService.login(request) + currentSessionId = response.sessionId + currentChallenge = response.challenge + currentChallengeId = response.challengeId + + // If we have a challenge, automatically sign and verify it + if (response.challenge != null && response.challengeId != null) { + signAndVerifyChallenge( + email, + did, + response.challenge, + response.challengeId, + response.sessionId, + response, + ) + } else { + // No challenge needed (subsequent login) + _loginResult.value = Result.success(response) + _isLoading.value = false + } + } catch (e: Exception) { + _loginResult.value = Result.failure(e) + _isLoading.value = false + } + } + } + + private fun signAndVerifyChallenge( + email: String, + did: String, + challenge: String, + challengeId: String, + sessionId: String, + originalResponse: LoginResponse, + ) { + viewModelScope.launch { + try { + // Get the private key from secure storage + val privateKey = secureStorage.getPrivateKey() + if (privateKey == null) { + _loginResult.value = + Result.failure(Exception("No private key found. Please regenerate your DID.")) + _isLoading.value = false + return@launch + } + + // Sign the challenge + val signature = Ed25519Utils.signChallenge(challenge, privateKey) + + // Send verification request + val verifyRequest = + VerifyRequest( + did = did, + challengeId = challengeId, + signature = signature, + sessionId = sessionId, + email = email, + ) + + val verifyResponse = apiService.verify(verifyRequest) + verifyResult.value = Result.success(verifyResponse) + + // After successful verification, use the original response + // The server now considers this session verified + _loginResult.value = Result.success(originalResponse) + _isLoading.value = false + } catch (e: Exception) { + _loginResult.value = + Result.failure(Exception("Challenge verification failed: ${e.message}")) + _isLoading.value = false + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaMediaViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaMediaViewModel.kt new file mode 100644 index 000000000..125c4c5a3 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaMediaViewModel.kt @@ -0,0 +1,247 @@ +package net.opendasharchive.openarchive.services.storacha.viewModel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.services.storacha.model.UploadEntry +import net.opendasharchive.openarchive.services.storacha.model.UploadResponse +import net.opendasharchive.openarchive.services.storacha.service.StorachaApiService +import net.opendasharchive.openarchive.services.storacha.util.BridgeUploader +import net.opendasharchive.openarchive.services.storacha.util.CarFileResult +import retrofit2.HttpException +import timber.log.Timber +import java.io.File + +enum class LoadingState { + NONE, + LOADING_FILES, + LOADING_MORE, + UPLOADING, +} + +class StorachaMediaViewModel( + private val apiService: StorachaApiService, + private val bridgeUploader: BridgeUploader, +) : ViewModel() { + companion object { + private const val PAGE_SIZE = 20 + private const val HTTP_UNAUTHORIZED = 401 + } + + private val _media = MutableLiveData>(emptyList()) + val media: LiveData> get() = _media + + private val _loading = MutableLiveData() + val loading: LiveData get() = _loading + + private val _loadingState = MutableLiveData(LoadingState.NONE) + val loadingState: LiveData get() = _loadingState + + private val _isEmpty = MutableLiveData() + val isEmpty: LiveData get() = _isEmpty + + private val _uploadResult = MutableLiveData?>() + val uploadResult: LiveData?> get() = _uploadResult + + private val _sessionExpired = MutableLiveData() + val sessionExpired: LiveData get() = _sessionExpired + + private var paginationState = PaginationState() + + var onLoadComplete: (() -> Unit)? = null + + fun reset() { + paginationState = PaginationState() + _media.value = emptyList() + _isEmpty.value = false + } + + fun refreshFromStart() { + paginationState = + paginationState.copy( + cursor = null, + hasMoreData = true, + isFirstLoad = true, + isRefreshing = true, + ) + } + + fun clearUploadResult() { + _uploadResult.value = null + } + + fun loadMoreMediaEntries( + userDid: String, + spaceDid: String, + sessionId: String?, + ) { + if (paginationState.isLoading || !paginationState.hasMoreData) return + + _loading.value = true + paginationState = + paginationState.copy( + isLoading = true, + loadingState = if (paginationState.isFirstLoad) LoadingState.LOADING_FILES else LoadingState.LOADING_MORE, + ) + _loadingState.value = paginationState.loadingState + + viewModelScope.launch { + try { + Timber.d("Loading page: cursor=${paginationState.cursor?.take(15)}...") + + val response = + apiService.listUploads( + userDid = userDid, + spaceDid = spaceDid, + cursor = paginationState.cursor, + size = PAGE_SIZE, + sessionId = sessionId?.takeIf { it.isNotEmpty() }, + ) + + handleLoadSuccess(response.uploads, response.hasMore) + } catch (e: HttpException) { + handleLoadError(e) + } catch (e: Exception) { + handleLoadError(e) + } finally { + _loading.value = false + paginationState = + paginationState.copy( + isLoading = false, + loadingState = LoadingState.NONE, + ) + _loadingState.value = LoadingState.NONE + + if (paginationState.hasMoreData) { + onLoadComplete?.invoke() + } + } + } + } + + fun uploadFile( + file: File, + carResult: CarFileResult, + userDid: String, + spaceDid: String, + sessionId: String?, + isAdmin: Boolean = false, + ) { + viewModelScope.launch { + _loading.value = true + _loadingState.value = LoadingState.UPLOADING + try { + val bridgeResult = + bridgeUploader.uploadFile( + carFile = carResult.carFile, + carCid = carResult.carCid, + rootCid = carResult.rootCid, + spaceDid = spaceDid, + userDid = userDid, + sessionId = sessionId, + isAdmin = isAdmin, + ) + + _uploadResult.value = + Result.success( + UploadResponse( + success = true, + cid = bridgeResult.rootCid, + size = bridgeResult.size, + ), + ) + + refreshFromStart() + loadMoreMediaEntries(userDid, spaceDid, sessionId) + } catch (e: HttpException) { + if (e.code() == HTTP_UNAUTHORIZED) { + _sessionExpired.value = true + } + _uploadResult.value = Result.failure(e) + } catch (e: Exception) { + _uploadResult.value = Result.failure(e) + } finally { + cleanupCarFile(carResult.carFile) + _loading.value = false + _loadingState.value = LoadingState.NONE + } + } + } + + private fun handleLoadSuccess( + newEntries: List, + hasMore: Boolean, + ) { + val currentEntries = _media.value ?: emptyList() + val isReplacingList = + paginationState.isRefreshing || (paginationState.isFirstLoad && currentEntries.isEmpty()) + + val updatedEntries = + if (isReplacingList) { + newEntries + } else { + val existingCids = currentEntries.map { it.cid }.toSet() + currentEntries + newEntries.filterNot { existingCids.contains(it.cid) } + } + + _media.value = updatedEntries + + val addedCount = + if (isReplacingList) { + newEntries.size + } else { + updatedEntries.size - currentEntries.size + } + + Timber.d("Loaded: total=${updatedEntries.size}, added=$addedCount, hasMore=$hasMore") + + if (paginationState.isFirstLoad) { + _isEmpty.value = updatedEntries.isEmpty() + } + + // Use last item's CID as cursor for next page (workaround for server pagination bug) + val nextCursor = newEntries.lastOrNull()?.cid + val actualHasMore = newEntries.isNotEmpty() && addedCount > 0 && hasMore + + paginationState = + paginationState.copy( + cursor = nextCursor, + hasMoreData = actualHasMore, + isFirstLoad = false, + isRefreshing = false, + ) + } + + private fun handleLoadError(error: Exception) { + if (error is HttpException && error.code() == HTTP_UNAUTHORIZED) { + _sessionExpired.value = true + } + Timber.e(error, "Failed to load page") + paginationState = + paginationState.copy( + isRefreshing = false, + ) + } + + private fun cleanupCarFile(carFile: File) { + try { + if (carFile.exists()) { + carFile.delete() + Timber.d("Deleted temporary CAR file: ${carFile.name}") + } + } catch (e: Exception) { + Timber.e(e, "Failed to delete CAR file") + } + } + + private data class PaginationState( + val cursor: String? = null, + val hasMoreData: Boolean = true, + val isLoading: Boolean = false, + val isFirstLoad: Boolean = true, + val isRefreshing: Boolean = false, + val loadingState: LoadingState = LoadingState.NONE, + ) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaViewDIDsViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaViewDIDsViewModel.kt new file mode 100644 index 000000000..e20eb7c50 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/services/storacha/viewModel/StorachaViewDIDsViewModel.kt @@ -0,0 +1,83 @@ +package net.opendasharchive.openarchive.services.storacha.viewModel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.services.storacha.service.StorachaApiService +import retrofit2.HttpException + +data class DidAccount( + val did: String, +) + +class StorachaViewDIDsViewModel( + private val apiService: StorachaApiService, +) : ViewModel() { + private val _dids = MutableLiveData>() + val dids: LiveData> get() = _dids + + private val _loading = MutableLiveData() + val loading: LiveData get() = _loading + + private val _error = MutableLiveData() + val error: LiveData get() = _error + + private val _sessionExpired = MutableLiveData() + val sessionExpired: LiveData = _sessionExpired + + fun loadDIDs( + sessionId: String, + spaceDid: String, + ) { + _loading.value = true + _error.value = null + viewModelScope.launch { + try { + val response = + apiService.listDelegationsBySpace( + sessionId = sessionId, + spaceDid = spaceDid, + ) + val didAccounts = response.users?.map { DidAccount(it) } ?: emptyList() + _dids.value = didAccounts + } catch (e: HttpException) { + if (e.code() == 401) { + _sessionExpired.value = true + } + _error.value = e.message ?: "Failed to load DIDs" + _dids.value = emptyList() + } catch (e: Exception) { + _error.value = e.message ?: "Failed to load DIDs" + _dids.value = emptyList() + } finally { + _loading.value = false + } + } + } + + fun revokeDID( + sessionId: String, + spaceDid: String, + account: DidAccount, + ) { + viewModelScope.launch { + try { + val request = + net.opendasharchive.openarchive.services.storacha.model.DelegationRevokeRequest( + userDid = account.did, + spaceDid = spaceDid, + ) + apiService.revokeDelegation(sessionId, request) + + // Remove from local list on success + val currentDids = _dids.value?.toMutableList() ?: mutableListOf() + currentDids.remove(account) + _dids.value = currentDids + } catch (e: Exception) { + _error.value = e.message ?: "Failed to revoke DID" + } + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/Utility.kt b/app/src/main/java/net/opendasharchive/openarchive/util/Utility.kt index 08daa6790..26ff3095c 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/Utility.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/Utility.kt @@ -54,6 +54,17 @@ object Utility { return File(dir, "$timeStamp.$fileName") } + fun getOutputMediaFileByCacheNoTimestamp(context: Context, fileName: String): File? { + val dir = context.cacheDir + if (!dir.exists()) { + if (!dir.mkdirs()) { + return null + } + } + + return File(dir, fileName) + } + fun writeStreamToFile(input: InputStream?, file: File?): Boolean { @Suppress("NAME_SHADOWING") val input = input ?: return false diff --git a/app/src/main/res/drawable/ic_content_copy.xml b/app/src/main/res/drawable/ic_content_copy.xml new file mode 100644 index 000000000..ccad3fe7e --- /dev/null +++ b/app/src/main/res/drawable/ic_content_copy.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_doc.xml b/app/src/main/res/drawable/ic_doc.xml new file mode 100644 index 000000000..29bd48c06 --- /dev/null +++ b/app/src/main/res/drawable/ic_doc.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_email_verification.xml b/app/src/main/res/drawable/ic_email_verification.xml new file mode 100644 index 000000000..84583b57b --- /dev/null +++ b/app/src/main/res/drawable/ic_email_verification.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pdf_document.xml b/app/src/main/res/drawable/ic_pdf_document.xml new file mode 100644 index 000000000..789df3e69 --- /dev/null +++ b/app/src/main/res/drawable/ic_pdf_document.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_qr_code.xml b/app/src/main/res/drawable/ic_qr_code.xml new file mode 100644 index 000000000..857cd75bc --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_code.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_storacha_email_verification.xml b/app/src/main/res/drawable/ic_storacha_email_verification.xml new file mode 100644 index 000000000..694fd8075 --- /dev/null +++ b/app/src/main/res/drawable/ic_storacha_email_verification.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_video_document.xml b/app/src/main/res/drawable/ic_video_document.xml new file mode 100644 index 000000000..522465aac --- /dev/null +++ b/app/src/main/res/drawable/ic_video_document.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pill_bg_qr.xml b/app/src/main/res/drawable/pill_bg_qr.xml new file mode 100644 index 000000000..57762c46c --- /dev/null +++ b/app/src/main/res/drawable/pill_bg_qr.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pill_bg_st.xml b/app/src/main/res/drawable/pill_bg_st.xml new file mode 100644 index 000000000..d6f07b14d --- /dev/null +++ b/app/src/main/res/drawable/pill_bg_st.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/qr_background.xml b/app/src/main/res/drawable/qr_background.xml new file mode 100644 index 000000000..f8b7fe110 --- /dev/null +++ b/app/src/main/res/drawable/qr_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/qr_outlined.xml b/app/src/main/res/drawable/qr_outlined.xml new file mode 100644 index 000000000..ef0f03855 --- /dev/null +++ b/app/src/main/res/drawable/qr_outlined.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_dialog.xml b/app/src/main/res/drawable/rounded_dialog.xml index 5f6dc8173..c8157a902 100644 --- a/app/src/main/res/drawable/rounded_dialog.xml +++ b/app/src/main/res/drawable/rounded_dialog.xml @@ -3,6 +3,8 @@ android:shape="rectangle"> + android:topRightRadius="16dp" + android:bottomLeftRadius="16dp" + android:bottomRightRadius="16dp"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/storacha.xml b/app/src/main/res/drawable/storacha.xml new file mode 100644 index 000000000..4996b6742 --- /dev/null +++ b/app/src/main/res/drawable/storacha.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/storacha_logo.xml b/app/src/main/res/drawable/storacha_logo.xml new file mode 100644 index 000000000..14d450f1c --- /dev/null +++ b/app/src/main/res/drawable/storacha_logo.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_storacha.xml b/app/src/main/res/layout/fragment_storacha.xml new file mode 100644 index 000000000..ce3dc63f6 --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha.xml @@ -0,0 +1,321 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_storacha_account_details.xml b/app/src/main/res/layout/fragment_storacha_account_details.xml new file mode 100644 index 000000000..24f0aa873 --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha_account_details.xml @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_storacha_accounts.xml b/app/src/main/res/layout/fragment_storacha_accounts.xml new file mode 100644 index 000000000..0277986cd --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha_accounts.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_storacha_add_did.xml b/app/src/main/res/layout/fragment_storacha_add_did.xml new file mode 100644 index 000000000..62d18c2d7 --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha_add_did.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_storacha_browse_spaces.xml b/app/src/main/res/layout/fragment_storacha_browse_spaces.xml new file mode 100644 index 000000000..aa58607ed --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha_browse_spaces.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_storacha_client_qr.xml b/app/src/main/res/layout/fragment_storacha_client_qr.xml new file mode 100644 index 000000000..92217e0c5 --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha_client_qr.xml @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_storacha_content_picker.xml b/app/src/main/res/layout/fragment_storacha_content_picker.xml new file mode 100644 index 000000000..5997e21a9 --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha_content_picker.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_storacha_did_access.xml b/app/src/main/res/layout/fragment_storacha_did_access.xml new file mode 100644 index 000000000..f88e3c45a --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha_did_access.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_storacha_email_verification_sent.xml b/app/src/main/res/layout/fragment_storacha_email_verification_sent.xml new file mode 100644 index 000000000..a2059b721 --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha_email_verification_sent.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_storacha_login.xml b/app/src/main/res/layout/fragment_storacha_login.xml new file mode 100644 index 000000000..521682cbd --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha_login.xml @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_storacha_media.xml b/app/src/main/res/layout/fragment_storacha_media.xml new file mode 100644 index 000000000..2273f6c22 --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha_media.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_storacha_space_setup_success.xml b/app/src/main/res/layout/fragment_storacha_space_setup_success.xml new file mode 100644 index 000000000..e30872f1c --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha_space_setup_success.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_storacha_view_dids.xml b/app/src/main/res/layout/fragment_storacha_view_dids.xml new file mode 100644 index 000000000..b44ffe404 --- /dev/null +++ b/app/src/main/res/layout/fragment_storacha_view_dids.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_space_usage.xml b/app/src/main/res/layout/item_space_usage.xml new file mode 100644 index 000000000..2217a860b --- /dev/null +++ b/app/src/main/res/layout/item_space_usage.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/storacha_did_row.xml b/app/src/main/res/layout/storacha_did_row.xml new file mode 100644 index 000000000..d6a11eece --- /dev/null +++ b/app/src/main/res/layout/storacha_did_row.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/storacha_media_grid_item.xml b/app/src/main/res/layout/storacha_media_grid_item.xml new file mode 100644 index 000000000..5a1d3c036 --- /dev/null +++ b/app/src/main/res/layout/storacha_media_grid_item.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/storacha_space_row.xml b/app/src/main/res/layout/storacha_space_row.xml new file mode 100644 index 000000000..987f8e6c7 --- /dev/null +++ b/app/src/main/res/layout/storacha_space_row.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/app_nav_graph.xml b/app/src/main/res/navigation/app_nav_graph.xml index 16baab2df..7b697568e 100644 --- a/app/src/main/res/navigation/app_nav_graph.xml +++ b/app/src/main/res/navigation/app_nav_graph.xml @@ -53,6 +53,13 @@ app:popEnterAnim="@anim/slide_in_left" app:popExitAnim="@anim/slide_out_right" /> + @@ -117,6 +124,233 @@ app:argType="long" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Username Email + Email Password @string/prompt_email @@ -184,7 +185,7 @@ - Select a Server + Select a server Private Server @@ -315,12 +316,12 @@ Secure Archive - Terms and Privacy + Terms & Privacy Policy Media Servers Archived Folders Manage your servers Manage your archived folders - Read our Terms and Privacy Policy + Read our Terms & Privacy Policy Success! @@ -338,6 +339,68 @@ Save by OpenArchive Learn More + + Storacha + Manage Accounts + My Spaces + Join Space + Create or edit accounts + Access your spaces + Join an existing shared space + You can add multiple private servers and one IA account at any time. + Learn more.]]> + 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" }