diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/HighlightActionHandlers.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/HighlightActionHandlers.kt index 6e9492aea..28b5c8d47 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/HighlightActionHandlers.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/HighlightActionHandlers.kt @@ -1,5 +1,6 @@ package app.omnivore.omnivore.dataService +import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput import app.omnivore.omnivore.graphql.generated.type.HighlightType import app.omnivore.omnivore.models.ServerSyncStatus import app.omnivore.omnivore.networking.* @@ -50,7 +51,7 @@ suspend fun DataService.createWebHighlight(jsonString: String, colorName: String } suspend fun DataService.createNoteHighlight(savedItemId: String, note: String): String { - val shortId = UUID.randomUUID().toString() + val shortId = NanoId.generate(size=14) val createHighlightId = UUID.randomUUID().toString() withContext(Dispatchers.IO) { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/NanoId.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/NanoId.kt new file mode 100644 index 000000000..e995f32fe --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/NanoId.kt @@ -0,0 +1,121 @@ +package app.omnivore.omnivore.dataService + + +import org.jetbrains.annotations.NotNull +import java.security.SecureRandom +import java.util.* +import kotlin.math.abs +import kotlin.math.ceil + +/** + * NanoId is a utility object providing functions for generating secure, URL-friendly, unique identifiers. + * + * The object offers methods for generating random strings with adjustable parameters like size, alphabet, + * overhead factor, and a custom random number generator. + * + * Example usage: + * ``` + * val id = NanoId.generate() + * ``` + */ +object NanoId { + + /** + * Generates a random string based on specified or default parameters. + * + * @param size The desired length of the generated string. Default is 21. + * @param alphabet The set of characters to choose from for generating the string. Default includes alphanumeric characters along with "_" and "-". + * @param additionalBytesFactor The additional bytes factor used for calculating the step size. Default is 1.6. + * @param random The random number generator to use. Default is `SecureRandom`. + * @return The generated random string. + * @throws IllegalArgumentException if the alphabet is empty or larger than 255 characters, or if the size is not greater than zero. + */ + @JvmOverloads + fun generate( + @NotNull + size: Int = 21, + @NotNull + alphabet: String = "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + @NotNull + additionalBytesFactor: Double = 1.6, + @NotNull + random: Random = SecureRandom() + ): String { + require(!(alphabet.isEmpty() || alphabet.length >= 256)) { "alphabet must contain between 1 and 255 symbols." } + require(size > 0) { "size must be greater than zero." } + require(additionalBytesFactor >= 1) { "additionalBytesFactor must be greater or equal 1." } + + val mask = calculateMask(alphabet) + val step = calculateStep(size, alphabet, additionalBytesFactor) + + return generateOptimized(size, alphabet, mask, step, random) + } + + /** + * Generates an optimized random string of a specified size using the given alphabet, mask, and step. + * Optionally, you can specify a custom random number generator. This optimized version is designed for + * higher performance and lower memory overhead. + * + * @param size The desired length of the generated string. + * @param alphabet The set of characters to choose from for generating the string. + * @param mask The mask used for mapping random bytes to alphabet indices. Should be `(2^n) - 1` where `n` is a power of 2 less than or equal to the alphabet size. + * @param step The number of random bytes to generate in each iteration. A larger value may speed up the function but increase memory usage. + * @param random The random number generator. Default is `SecureRandom`. + * @return The generated optimized string. + */ + @JvmOverloads + fun generateOptimized(@NotNull size: Int, @NotNull alphabet: String, @NotNull mask: Int, @NotNull step: Int, @NotNull random: Random = SecureRandom()): String { + val idBuilder = StringBuilder(size) + val bytes = ByteArray(step) + while (true) { + random.nextBytes(bytes) + for (i in 0 until step) { + val alphabetIndex = bytes[i].toInt() and mask + if (alphabetIndex < alphabet.length) { + idBuilder.append(alphabet[alphabetIndex]) + if (idBuilder.length == size) { + return idBuilder.toString() + } + } + } + } + } + + /** + * Calculates the optimal additional bytes factor needed for the generation of the step size, which is used to generate random bytes in each iteration. + * + * @param alphabet The set of characters to use for generating the string. + * @return The additional bytes factor, rounded to two decimal places. + */ + fun calculateAdditionalBytesFactor(@NotNull alphabet: String): Double { + val mask = calculateMask(alphabet) + return (1 + abs((mask - alphabet.length.toDouble()) / alphabet.length)).round(2) + } + + /** + * Calculates the mask used to map random bytes to indices in the alphabet. + * + * @param alphabet The set of characters to use for generating the string. + * @return The calculated mask value. + */ + fun calculateMask(@NotNull alphabet: String) = (2 shl (Integer.SIZE - 1 - Integer.numberOfLeadingZeros(alphabet.length - 1))) - 1 + + /** + * Calculates the number of random bytes to generate in each iteration for a given size and alphabet. + * + * @param size The length of the generated string. + * @param alphabet The set of characters to use for generating the string. + * @param additionalBytesFactor The additional bytes factor. Default value is calculated using `calculateAdditionalBytesFactor()`. + * @return The number of random bytes to generate in each iteration. + */ + @JvmOverloads + fun calculateStep(@NotNull size: Int, @NotNull alphabet: String, @NotNull additionalBytesFactor: Double = calculateAdditionalBytesFactor(alphabet)) = + ceil(additionalBytesFactor * calculateMask(alphabet) * size / alphabet.length).toInt() + + @JvmSynthetic + internal fun Double.round(decimals: Int): Double { + var multiplier = 1.0 + repeat(decimals) { multiplier *= 10 } + return kotlin.math.round(this * multiplier) / multiplier + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/HighlightMutations.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/HighlightMutations.kt index 2bc7033d4..19fcf3f06 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/HighlightMutations.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/HighlightMutations.kt @@ -139,6 +139,8 @@ suspend fun Networker.createHighlight(input: CreateHighlightInput): Highlight? { try { val result = authenticatedApolloClient().mutation(CreateHighlightMutation(input)).execute() + Log.d("Loggo", "result: ${result.data}") + val createdHighlight = result.data?.createHighlight?.onCreateHighlightSuccess?.highlight diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/LabelUtils.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/LabelUtils.kt new file mode 100644 index 000000000..8b6323820 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/LabelUtils.kt @@ -0,0 +1,59 @@ +package app.omnivore.omnivore.ui + +import app.omnivore.omnivore.dataService.DataService +import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput +import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput +import app.omnivore.omnivore.networking.Networker +import app.omnivore.omnivore.networking.updateLabelsForSavedItem +import app.omnivore.omnivore.persistence.entities.SavedItemAndSavedItemLabelCrossRef +import app.omnivore.omnivore.persistence.entities.SavedItemLabel +import com.apollographql.apollo3.api.Optional + + + +suspend fun setSavedItemLabels( + networker: Networker, + dataService: DataService, + savedItemID: String, + labels: List +): Boolean { + val input = SetLabelsInput( + pageId = savedItemID, + labels = Optional.presentIfNotNull(labels.map { CreateLabelInput(color = Optional.presentIfNotNull(it.color), name = it.name) }), + ) + + val updatedLabels = networker.updateLabelsForSavedItem(input) + + // Figure out which of the labels are new + updatedLabels?.let { updatedLabels -> + val existingNamedLabels = dataService.db.savedItemLabelDao() + .namedLabels(updatedLabels.map { it.labelFields.name }) + val existingNames = existingNamedLabels.map { it.name } + val newNamedLabels = updatedLabels.filter { !existingNames.contains(it.labelFields.name) } + + dataService.db.savedItemLabelDao().insertAll(newNamedLabels.map { + SavedItemLabel( + savedItemLabelId = it.labelFields.id, + name = it.labelFields.name, + color = it.labelFields.color, + createdAt = null, + labelDescription = null + ) + }) + + val allNamedLabels = dataService.db.savedItemLabelDao() + .namedLabels(updatedLabels.map { it.labelFields.name }) + val crossRefs = allNamedLabels.map { + SavedItemAndSavedItemLabelCrossRef( + savedItemLabelId = it.savedItemLabelId, + savedItemId = savedItemID + ) + } + dataService.db.savedItemAndSavedItemLabelCrossRefDao().deleteRefsBySavedItemId(savedItemID) + dataService.db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs) + + return true + } ?: run { + return false + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryViewModel.kt index 355337679..570a1e56a 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryViewModel.kt @@ -20,6 +20,8 @@ import app.omnivore.omnivore.models.ServerSyncStatus import app.omnivore.omnivore.networking.* import app.omnivore.omnivore.persistence.entities.* import app.omnivore.omnivore.ui.ResourceProvider +import app.omnivore.omnivore.ui.setSavedItemLabels +import coil.util.CoilUtils.result import com.apollographql.apollo3.api.Optional import com.apollographql.apollo3.api.Optional.Companion.presentIfNotNull import dagger.hilt.android.lifecycle.HiltViewModel @@ -305,43 +307,16 @@ class LibraryViewModel @Inject constructor( fun updateSavedItemLabels(savedItemID: String, labels: List) { viewModelScope.launch { withContext(Dispatchers.IO) { - val input = SetLabelsInput( - pageId = savedItemID, - labels = Optional.presentIfNotNull(labels.map { CreateLabelInput(color = Optional.presentIfNotNull(it.color), name = it.name) }), + val result = setSavedItemLabels( + networker = networker, + dataService = dataService, + savedItemID = savedItemID, + labels = labels ) - val updatedLabels = networker.updateLabelsForSavedItem(input) - - // Figure out which of the labels are new - updatedLabels?.let { updatedLabels -> - val existingNamedLabels = dataService.db.savedItemLabelDao() - .namedLabels(updatedLabels.map { it.labelFields.name }) - val existingNames = existingNamedLabels.map { it.name } - val newNamedLabels = updatedLabels.filter { !existingNames.contains(it.labelFields.name) } - - dataService.db.savedItemLabelDao().insertAll(newNamedLabels.map { - SavedItemLabel( - savedItemLabelId = it.labelFields.id, - name = it.labelFields.name, - color = it.labelFields.color, - createdAt = null, - labelDescription = null - ) - }) - - val allNamedLabels = dataService.db.savedItemLabelDao() - .namedLabels(updatedLabels.map { it.labelFields.name }) - val crossRefs = allNamedLabels.map { - SavedItemAndSavedItemLabelCrossRef( - savedItemLabelId = it.savedItemLabelId, - savedItemId = savedItemID - ) - } - dataService.db.savedItemAndSavedItemLabelCrossRefDao().deleteRefsBySavedItemId(savedItemID) - dataService.db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs) - + if (result) { snackbarMessage = resourceProvider.getString(R.string.library_view_model_snackbar_success) - } ?: run { + } else { snackbarMessage = resourceProvider.getString(R.string.library_view_model_snackbar_error) } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReaderViewModel.kt index 4410e077e..97c2df4a2 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReaderViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.omnivore.omnivore.DatastoreRepository import app.omnivore.omnivore.dataService.DataService +import app.omnivore.omnivore.dataService.NanoId import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput @@ -146,11 +147,11 @@ class PDFReaderViewModel @Inject constructor( fun syncHighlightUpdates(newAnnotation: Annotation, quote: String, overlapIds: List, note: String? = null) { val itemID = pdfReaderParamsLiveData.value?.item?.savedItemId ?: return val highlightID = UUID.randomUUID().toString() - val shortID = UUID.randomUUID().toString().replace("-","").substring(0,8) + val shortId = NanoId.generate(size=14) val jsonValues = JSONObject() .put("id", highlightID) - .put("shortId", shortID) + .put("shortId", shortId) .put("quote", quote) .put("articleId", itemID) @@ -164,7 +165,7 @@ class PDFReaderViewModel @Inject constructor( overlapHighlightIdList = overlapIds, patch = newAnnotation.toInstantJson(), quote = quote, - shortId = shortID + shortId = shortId ) viewModelScope.launch { @@ -177,7 +178,7 @@ class PDFReaderViewModel @Inject constructor( id = highlightID, patch = Optional.presentIfNotNull(newAnnotation.toInstantJson()), quote = Optional.presentIfNotNull(quote), - shortId = shortID, + shortId = shortId, ) viewModelScope.launch { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt index 50c01c788..480f018cf 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt @@ -23,6 +23,7 @@ import app.omnivore.omnivore.persistence.entities.SavedItemAndSavedItemLabelCros import app.omnivore.omnivore.persistence.entities.SavedItemLabel import app.omnivore.omnivore.ui.components.HighlightColor import app.omnivore.omnivore.ui.library.SavedItemAction +import app.omnivore.omnivore.ui.setSavedItemLabels import com.apollographql.apollo3.api.Optional import com.apollographql.apollo3.api.Optional.Companion.presentIfNotNull import com.google.gson.Gson @@ -500,38 +501,13 @@ class WebReaderViewModel @Inject constructor( fun updateSavedItemLabels(savedItemID: String, labels: List) { viewModelScope.launch { withContext(Dispatchers.IO) { - val namedLabels = dataService.db.savedItemLabelDao().namedLabels(labels.map { it.name }) - namedLabels.filter { it.serverSyncStatus != ServerSyncStatus.IS_SYNCED.rawValue }.mapNotNull { - val result = networker.createNewLabel(CreateLabelInput(color = presentIfNotNull(it.color), name = it.name)) - result?.let { it1 -> - SavedItemLabel( - savedItemLabelId = it1.id, - name = result.name, - color = result.color, - createdAt = result.createdAt.toString(), - labelDescription = result.description, - serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue - ) - } - } - - val input = SetLabelsInput(labelIds = Optional.presentIfNotNull(namedLabels.map { it.savedItemLabelId }), pageId = savedItemID) - val networkResult = networker.updateLabelsForSavedItem(input) - - // TODO: assign a server sync status to these - val crossRefs = namedLabels.map { - SavedItemAndSavedItemLabelCrossRef( - savedItemLabelId = it.savedItemLabelId, - savedItemId = savedItemID - ) - } - - // Remove all labels first - dataService.db.savedItemAndSavedItemLabelCrossRefDao().deleteRefsBySavedItemId(savedItemID) - - // Add back the current labels - dataService.db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs) + setSavedItemLabels( + networker = networker, + dataService = dataService, + savedItemID = savedItemID, + labels = labels + ) slug?.let { loadItemFromDB(it)