Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ captures/
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839

# User-specific stuff
.idea/planningMode.xml
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
Expand Down
8 changes: 8 additions & 0 deletions .idea/markdown.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@
<data android:pathPrefix="/recipe"/>
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>

</manifest>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import java.net.UnknownHostException
import javax.net.ssl.SSLHandshakeException

open class BaseRepository {
/**
* Handles network and API response errors, mapping them to [Resource.Error] with user-friendly messages.
*
* @param t The throwable to handle (e.g., [HttpException], [UnknownHostException]).
* @param serverMessage An optional custom message from the server.
* @param code An optional HTTP status code.
*/
fun <T> handleResponseError(
t: Throwable?,
serverMessage: String? = null,
Expand All @@ -31,6 +38,7 @@ open class BaseRepository {
403 -> UiText.StringResource(R.string.error_http_403)
404 -> UiText.StringResource(R.string.error_http_404)
405 -> UiText.StringResource(R.string.error_http_405)
409 -> UiText.StringResource(R.string.error_http_409)
500 -> UiText.StringResource(R.string.error_http_500)
503 -> UiText.StringResource(R.string.error_http_503)
else -> unknownErrorUiText(t)
Expand All @@ -47,6 +55,7 @@ open class BaseRepository {
403 -> UiText.StringResource(R.string.error_http_403)
404 -> UiText.StringResource(R.string.error_http_404)
405 -> UiText.StringResource(R.string.error_http_405)
409 -> UiText.StringResource(R.string.error_http_409)
500 -> UiText.StringResource(R.string.error_http_500)
503 -> UiText.StringResource(R.string.error_http_503)
else -> unknownErrorUiText(t)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.lukasneugebauer.nextcloudcookbook.core.data.PreferencesManager
import de.lukasneugebauer.nextcloudcookbook.core.data.api.NcCookbookApiProvider
import de.lukasneugebauer.nextcloudcookbook.core.util.IoDispatcher
import de.lukasneugebauer.nextcloudcookbook.core.util.OkHttpClientProvider
import de.lukasneugebauer.nextcloudcookbook.recipe.data.RecipeFormatterImpl
import de.lukasneugebauer.nextcloudcookbook.recipe.data.YieldCalculatorImpl
import de.lukasneugebauer.nextcloudcookbook.recipe.data.dto.RecipeDto
Expand Down Expand Up @@ -77,6 +79,8 @@ object RecipeModule {
apiProvider: NcCookbookApiProvider,
@ApplicationContext context: Context,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
preferencesManager: PreferencesManager,
clientProvider: OkHttpClientProvider,
recipesByCategoryStore: RecipePreviewsByCategoryStore,
recipePreviewsStore: RecipePreviewsStore,
recipeStore: RecipeStore,
Expand All @@ -86,6 +90,8 @@ object RecipeModule {
apiProvider,
context.imageLoader,
ioDispatcher,
preferencesManager,
clientProvider,
recipesByCategoryStore,
recipePreviewsStore,
recipeStore,
Expand Down
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer if we do not handle OkHttp directly and instead use retrofit like with the other requests in the app. Some stuff - like the auth header - is already handled that way and the repository class is cleaner.

Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,37 @@ import coil3.ImageLoader
import coil3.memory.MemoryCache
import com.haroldadmin.cnradapter.NetworkResponse
import de.lukasneugebauer.nextcloudcookbook.R
import de.lukasneugebauer.nextcloudcookbook.core.data.PreferencesManager
import de.lukasneugebauer.nextcloudcookbook.core.data.api.NcCookbookApiProvider
import de.lukasneugebauer.nextcloudcookbook.core.domain.model.NcAccount
import de.lukasneugebauer.nextcloudcookbook.core.domain.repository.BaseRepository
import de.lukasneugebauer.nextcloudcookbook.core.util.IoDispatcher
import de.lukasneugebauer.nextcloudcookbook.core.util.OkHttpClientProvider
import de.lukasneugebauer.nextcloudcookbook.core.util.Resource
import de.lukasneugebauer.nextcloudcookbook.core.util.SimpleResource
import de.lukasneugebauer.nextcloudcookbook.core.util.UiText
import de.lukasneugebauer.nextcloudcookbook.core.util.addSuffix
import de.lukasneugebauer.nextcloudcookbook.di.CategoriesStore
import de.lukasneugebauer.nextcloudcookbook.di.RecipePreviewsByCategoryStore
import de.lukasneugebauer.nextcloudcookbook.di.RecipePreviewsStore
import de.lukasneugebauer.nextcloudcookbook.di.RecipeStore
import de.lukasneugebauer.nextcloudcookbook.recipe.data.dto.ImportUrlDto
import de.lukasneugebauer.nextcloudcookbook.recipe.data.dto.RecipeDto
import de.lukasneugebauer.nextcloudcookbook.recipe.data.dto.RecipePreviewDto
import de.lukasneugebauer.nextcloudcookbook.recipe.domain.model.RecipeImageUpload
import de.lukasneugebauer.nextcloudcookbook.recipe.domain.repository.RecipeRepository
import de.lukasneugebauer.nextcloudcookbook.recipe.util.emptyRecipeDto
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import okhttp3.Credentials
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.mobilenativefoundation.store.store5.ExperimentalStoreApi
import org.mobilenativefoundation.store.store5.StoreReadRequest
import org.mobilenativefoundation.store.store5.StoreReadResponse
Expand All @@ -36,6 +48,8 @@ class RecipeRepositoryImpl
private val apiProvider: NcCookbookApiProvider,
private val imageLoader: ImageLoader,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val preferencesManager: PreferencesManager,
private val clientProvider: OkHttpClientProvider,
private val recipePreviewsByCategoryStore: RecipePreviewsByCategoryStore,
private val recipePreviewsStore: RecipePreviewsStore,
private val recipeStore: RecipeStore,
Expand All @@ -60,6 +74,13 @@ class RecipeRepositoryImpl

override suspend fun getRecipe(id: String): RecipeDto = recipeStore.get(id)

/**
* Creates a new recipe on the server.
*
* @param recipe The [RecipeDto] containing the recipe data.
* @return A [Resource] containing the new recipe ID on success, or an error message on failure.
* A 409 Conflict error usually indicates the recipe name already exists.
*/
override suspend fun createRecipe(recipe: RecipeDto): Resource<String> {
return withContext(ioDispatcher) {
val api =
Expand All @@ -68,14 +89,78 @@ class RecipeRepositoryImpl

try {
val id = api.createRecipe(recipe = recipe)
refreshCaches(id = recipe.id, categoryName = recipe.recipeCategory)
refreshCaches(id = id, categoryName = recipe.recipeCategory)
Resource.Success(data = id)
} catch (e: Exception) {
handleResponseError(e.fillInStackTrace())
}
}
}

override suspend fun uploadRecipeImage(image: RecipeImageUpload): Resource<String> {
return withContext(ioDispatcher) {
if (image.fileName.isBlank() || image.mimeType.isBlank() || image.bytes.isEmpty()) {
return@withContext Resource.Error(message = UiText.StringResource(R.string.error_invalid_image_payload))
}

val ncAccount = preferencesManager.preferencesFlow.first().ncAccount
if (ncAccount.username.isBlank() || ncAccount.token.isBlank() || ncAccount.url.isBlank()) {
return@withContext Resource.Error(message = UiText.StringResource(R.string.error_no_account_data))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

try {
val client = clientProvider.getCurrentClient()
val authHeader = Credentials.basic(ncAccount.username, ncAccount.token)
val userId = getWebDavUserId(fallback = ncAccount.username)
val uploadFolderUrl =
ncAccount.toWebDavUrl(
userId = userId,
pathSegments = listOf(RECIPE_IMAGE_UPLOAD_FOLDER),
)
val fileUrl =
ncAccount.toWebDavUrl(
userId = userId,
pathSegments = listOf(RECIPE_IMAGE_UPLOAD_FOLDER, image.fileName),
)

client
.newCall(
Request
.Builder()
.url(uploadFolderUrl)
.header("Authorization", authHeader)
.method("MKCOL", null)
.build(),
).execute()
.use { response ->
if (!response.isSuccessful && response.code != HTTP_METHOD_NOT_ALLOWED) {
return@withContext handleUploadError(response)
}
}

val body = image.bytes.toRequestBody(image.mimeType.toMediaType())
client
.newCall(
Request
.Builder()
.url(fileUrl)
.header("Authorization", authHeader)
.put(body)
.build(),
).execute()
.use { response ->
if (!response.isSuccessful) {
return@withContext handleUploadError(response)
}
}

Resource.Success(data = "/$RECIPE_IMAGE_UPLOAD_FOLDER/${image.fileName}")
} catch (e: Exception) {
handleResponseError(e.fillInStackTrace())
}
}
}

override suspend fun updateRecipe(recipe: RecipeDto): SimpleResource {
return withContext(ioDispatcher) {
val api =
Expand Down Expand Up @@ -159,6 +244,33 @@ class RecipeRepositoryImpl
imageLoader.diskCache?.remove(cacheKey)
}

private suspend fun getWebDavUserId(fallback: String): String =
when (val response = apiProvider.getApi()?.getCurrentUser()) {
is NetworkResponse.Success -> response.body.ocs.data.id
else -> fallback
}

private fun NcAccount.toWebDavUrl(
userId: String,
pathSegments: List<String>,
): HttpUrl {
val builder =
url
.addSuffix("/")
.toHttpUrl()
.newBuilder()
.addPathSegments("remote.php/dav/files")
.addPathSegment(userId)

pathSegments.forEach { pathSegment ->
builder.addPathSegment(pathSegment)
}

return builder.build()
}

private fun <T> handleUploadError(response: Response): Resource.Error<T> = handleResponseError(t = null, code = response.code)

@OptIn(ExperimentalStoreApi::class)
private suspend fun refreshCaches(
id: String,
Expand All @@ -176,4 +288,9 @@ class RecipeRepositoryImpl
recipeStore.fresh(id)
}
}

private companion object {
const val HTTP_METHOD_NOT_ALLOWED = 405
const val RECIPE_IMAGE_UPLOAD_FOLDER = "Cookbook uploads"
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I like this hard coded folder name. Best case would be to mimic the same behaviour as in the webapp and only have a temporary file.
https://github.com/nextcloud/cookbook/blob/master/lib/Helper/DownloadHelper.php
But since thats probably not done easily and reliably it would at the very least be good to move the app upload folder into the selected directory from the user settings. That way we are already in a generated folder and don't interfer with anything or create a mess in the home directory.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package de.lukasneugebauer.nextcloudcookbook.recipe.domain.model

data class RecipeImageUpload(
val fileName: String,
val mimeType: String,
val bytes: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

other as RecipeImageUpload

if (fileName != other.fileName) return false
if (mimeType != other.mimeType) return false
if (!bytes.contentEquals(other.bytes)) return false

return true
}

override fun hashCode(): Int {
var result = fileName.hashCode()
result = 31 * result + mimeType.hashCode()
result = 31 * result + bytes.contentHashCode()
return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import de.lukasneugebauer.nextcloudcookbook.core.util.SimpleResource
import de.lukasneugebauer.nextcloudcookbook.recipe.data.dto.ImportUrlDto
import de.lukasneugebauer.nextcloudcookbook.recipe.data.dto.RecipeDto
import de.lukasneugebauer.nextcloudcookbook.recipe.data.dto.RecipePreviewDto
import de.lukasneugebauer.nextcloudcookbook.recipe.domain.model.RecipeImageUpload
import kotlinx.coroutines.flow.Flow
import org.mobilenativefoundation.store.store5.StoreReadResponse

Expand All @@ -27,4 +28,6 @@ interface RecipeRepository {
): SimpleResource

suspend fun importRecipe(url: ImportUrlDto): Resource<RecipeDto>

suspend fun uploadRecipeImage(image: RecipeImageUpload): Resource<String>
}
Loading
Loading