diff --git a/app/src/main/java/com/skeler/scanely/core/di/LookupModule.kt b/app/src/main/java/com/skeler/scanely/core/di/LookupModule.kt index 6e587a7..ed46709 100644 --- a/app/src/main/java/com/skeler/scanely/core/di/LookupModule.kt +++ b/app/src/main/java/com/skeler/scanely/core/di/LookupModule.kt @@ -39,7 +39,7 @@ object LookupModule { .readTimeout(15, TimeUnit.SECONDS) .addInterceptor { chain -> val request = chain.request().newBuilder() - .header("User-Agent", "Scanly Android App - https://github.com/Azyrn/Scanly") + .header("User-Agent", OpenFoodFactsApi.USER_AGENT) .build() chain.proceed(request) } diff --git a/app/src/main/java/com/skeler/scanely/core/food/FoodProduct.kt b/app/src/main/java/com/skeler/scanely/core/food/FoodProduct.kt index 6c71884..72d55c3 100644 --- a/app/src/main/java/com/skeler/scanely/core/food/FoodProduct.kt +++ b/app/src/main/java/com/skeler/scanely/core/food/FoodProduct.kt @@ -23,9 +23,14 @@ data class FoodProduct( @Serializable data class OpenFoodFactsResponse( - val status: Int, - val product: ProductDto? -) + val status: String = "", + val product: ProductDto? = null +) { + val isFound: Boolean get() = status == OFF_STATUS_SUCCESS && product != null +} + +// API v3 reports outcome as a string; a missing product still returns HTTP 200. +const val OFF_STATUS_SUCCESS = "success" @Serializable data class ProductDto( diff --git a/app/src/main/java/com/skeler/scanely/core/food/FoodRepository.kt b/app/src/main/java/com/skeler/scanely/core/food/FoodRepository.kt index db9205d..ee00976 100644 --- a/app/src/main/java/com/skeler/scanely/core/food/FoodRepository.kt +++ b/app/src/main/java/com/skeler/scanely/core/food/FoodRepository.kt @@ -25,7 +25,7 @@ class FoodRepository @Inject constructor() { .readTimeout(10, TimeUnit.SECONDS) .addInterceptor { chain -> val request = chain.request().newBuilder() - .header("User-Agent", "Scanly Android App - https://github.com/Azyrn/Scanly") + .header("User-Agent", OpenFoodFactsApi.USER_AGENT) .build() chain.proceed(request) } @@ -43,8 +43,8 @@ class FoodRepository @Inject constructor() { Log.d(TAG, "Looking up product: $barcode") val response = api.getProduct(barcode) - if (response.status == 1 && response.product != null) { - val product = response.product.toDomain() + if (response.isFound) { + val product = response.product?.toDomain() Log.d(TAG, "Found product: ${product?.name}") Result.success(product) } else { diff --git a/app/src/main/java/com/skeler/scanely/core/food/OpenFoodFactsApi.kt b/app/src/main/java/com/skeler/scanely/core/food/OpenFoodFactsApi.kt index d48156e..71f25c9 100644 --- a/app/src/main/java/com/skeler/scanely/core/food/OpenFoodFactsApi.kt +++ b/app/src/main/java/com/skeler/scanely/core/food/OpenFoodFactsApi.kt @@ -1,12 +1,13 @@ package com.skeler.scanely.core.food +import com.skeler.scanely.BuildConfig import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query interface OpenFoodFactsApi { - @GET("api/v2/product/{barcode}.json") + @GET("api/v3/product/{barcode}.json") suspend fun getProduct( @Path("barcode") barcode: String, @Query("fields") fields: String = FIELDS @@ -15,6 +16,9 @@ interface OpenFoodFactsApi { companion object { const val BASE_URL = "https://world.openfoodfacts.org/" + // Open Food Facts asks for AppName/Version (contact) so clients aren't taken for bots. + val USER_AGENT = "Scanly/${BuildConfig.VERSION_NAME} (https://github.com/Azyrn/Scanly)" + // Only mapped fields — ~20x smaller payloads. const val FIELDS = "code,product_name,brands,image_front_url,image_url," + diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenBeautyFactsEngine.kt b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenBeautyFactsEngine.kt index 33732bd..d575a38 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenBeautyFactsEngine.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenBeautyFactsEngine.kt @@ -1,6 +1,7 @@ package com.skeler.scanely.core.lookup.engines import android.util.Log +import com.skeler.scanely.core.food.OFF_STATUS_SUCCESS import com.skeler.scanely.core.lookup.CosmeticsData import com.skeler.scanely.core.lookup.LookupEngine import com.skeler.scanely.core.lookup.LookupJson @@ -34,7 +35,7 @@ class OpenBeautyFactsEngine @Inject constructor( override suspend fun lookup(barcode: String): LookupResult = withContext(Dispatchers.IO) { try { - val url = "https://world.openbeautyfacts.org/api/v2/product/$barcode.json?fields=$FIELDS" + val url = "https://world.openbeautyfacts.org/api/v3/product/$barcode.json?fields=$FIELDS" Log.d(TAG, "Looking up: $barcode") val request = Request.Builder().url(url).build() @@ -45,8 +46,10 @@ class OpenBeautyFactsEngine @Inject constructor( val productResponse = LookupJson.decodeFromString(body) - if (productResponse.status == 1 && productResponse.product != null) { - val product = mapToProductInfo(barcode, productResponse.product) + val found = productResponse.product?.takeIf { productResponse.status == OFF_STATUS_SUCCESS } + + if (found != null) { + val product = mapToProductInfo(barcode, found) Log.d(TAG, "Found: ${product.name}") LookupResult.Found(product, name) } else { @@ -80,7 +83,7 @@ class OpenBeautyFactsEngine @Inject constructor( @Serializable data class BeautyProductResponse( - val status: Int = 0, + val status: String = "", val product: BeautyProduct? = null ) diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenFoodFactsEngine.kt b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenFoodFactsEngine.kt index e933078..72ddb59 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenFoodFactsEngine.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenFoodFactsEngine.kt @@ -31,8 +31,8 @@ class OpenFoodFactsEngine @Inject constructor( Log.d(TAG, "Looking up: $barcode") val response = api.getProduct(barcode) - if (response.status == 1 && response.product != null) { - val food = response.product.toDomain() + if (response.isFound) { + val food = response.product?.toDomain() if (food != null) { val product = mapToProductInfo(barcode, food) Log.d(TAG, "Found: ${product.name}") diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenPetFoodFactsEngine.kt b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenPetFoodFactsEngine.kt index 466a906..7f44cb6 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenPetFoodFactsEngine.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenPetFoodFactsEngine.kt @@ -1,6 +1,7 @@ package com.skeler.scanely.core.lookup.engines import android.util.Log +import com.skeler.scanely.core.food.OFF_STATUS_SUCCESS import com.skeler.scanely.core.lookup.FoodData import com.skeler.scanely.core.lookup.LookupEngine import com.skeler.scanely.core.lookup.LookupJson @@ -34,7 +35,7 @@ class OpenPetFoodFactsEngine @Inject constructor( override suspend fun lookup(barcode: String): LookupResult = withContext(Dispatchers.IO) { try { - val url = "https://world.openpetfoodfacts.org/api/v2/product/$barcode.json?fields=$FIELDS" + val url = "https://world.openpetfoodfacts.org/api/v3/product/$barcode.json?fields=$FIELDS" Log.d(TAG, "Looking up: $barcode") val request = Request.Builder().url(url).build() @@ -45,8 +46,10 @@ class OpenPetFoodFactsEngine @Inject constructor( val productResponse = LookupJson.decodeFromString(body) - if (productResponse.status == 1 && productResponse.product != null) { - val product = mapToProductInfo(barcode, productResponse.product) + val found = productResponse.product?.takeIf { productResponse.status == OFF_STATUS_SUCCESS } + + if (found != null) { + val product = mapToProductInfo(barcode, found) Log.d(TAG, "Found: ${product.name}") LookupResult.Found(product, name) } else { @@ -89,7 +92,7 @@ class OpenPetFoodFactsEngine @Inject constructor( @Serializable data class PetFoodProductResponse( - val status: Int = 0, + val status: String = "", val product: PetFoodProduct? = null ) diff --git a/app/src/test/java/com/skeler/scanely/core/food/OpenFoodFactsV3ResponseTest.kt b/app/src/test/java/com/skeler/scanely/core/food/OpenFoodFactsV3ResponseTest.kt new file mode 100644 index 0000000..c2f524b --- /dev/null +++ b/app/src/test/java/com/skeler/scanely/core/food/OpenFoodFactsV3ResponseTest.kt @@ -0,0 +1,78 @@ +package com.skeler.scanely.core.food + +import com.skeler.scanely.core.lookup.LookupJson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Payloads trimmed from live api/v3 responses on world.openfoodfacts.org. */ +class OpenFoodFactsV3ResponseTest { + + private val found = """ + { + "code": "3017620422003", + "errors": [], + "product": { + "brands": "Nutella, Ferrero", + "categories": "Spreads", + "code": "3017620422003", + "image_front_url": "https://images.openfoodfacts.org/front_en.400.jpg", + "image_url": "https://images.openfoodfacts.org/front_en.400.jpg", + "ingredients_text": "Sugar, palm oil, hazelnuts", + "nova_group": 4, + "nutriments": { + "energy-kcal_100g": 539, + "fat_100g": 30.9, + "carbohydrates_100g": 57.5, + "proteins_100g": 6.3, + "sugars_100g": 56.3, + "salt_100g": 0.107 + }, + "nutriscore_grade": "e", + "product_name": "Nutella" + }, + "result": { "id": "product_found", "name": "Product found" }, + "status": "success", + "warnings": [] + } + """.trimIndent() + + private val notFound = """ + { + "code": "9999999999994", + "errors": [ + { "field": { "id": "code" }, "message": { "id": "product_not_found" } } + ], + "result": { "id": "product_not_found", "name": "Product not found" }, + "status": "failure", + "warnings": [] + } + """.trimIndent() + + @Test + fun `v3 success payload decodes into a product`() { + val response = LookupJson.decodeFromString(found) + + assertTrue(response.isFound) + + val product = response.product?.toDomain() + assertEquals("Nutella", product?.name) + assertEquals("Nutella, Ferrero", product?.brand) + assertEquals("E", product?.nutriScore) + assertEquals(4, product?.novaGroup) + assertEquals("539 kcal", product?.calories) + assertEquals("30.9g", product?.fat) + assertEquals("0.11g", product?.salt) + assertEquals("Spreads", product?.categories) + } + + @Test + fun `v3 failure payload has no product and is not reported as found`() { + val response = LookupJson.decodeFromString(notFound) + + assertFalse(response.isFound) + assertNull(response.product) + } +}