Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
11 changes: 8 additions & 3 deletions app/src/main/java/com/skeler/scanely/core/food/FoodProduct.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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," +
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -45,8 +46,10 @@ class OpenBeautyFactsEngine @Inject constructor(

val productResponse = LookupJson.decodeFromString<BeautyProductResponse>(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 {
Expand Down Expand Up @@ -80,7 +83,7 @@ class OpenBeautyFactsEngine @Inject constructor(

@Serializable
data class BeautyProductResponse(
val status: Int = 0,
val status: String = "",
val product: BeautyProduct? = null
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -45,8 +46,10 @@ class OpenPetFoodFactsEngine @Inject constructor(

val productResponse = LookupJson.decodeFromString<PetFoodProductResponse>(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 {
Expand Down Expand Up @@ -89,7 +92,7 @@ class OpenPetFoodFactsEngine @Inject constructor(

@Serializable
data class PetFoodProductResponse(
val status: Int = 0,
val status: String = "",
val product: PetFoodProduct? = null
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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<OpenFoodFactsResponse>(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<OpenFoodFactsResponse>(notFound)

assertFalse(response.isFound)
assertNull(response.product)
}
}
Loading