Compare commits

...

10 Commits

64 changed files with 2547 additions and 502 deletions

2
.gitignore vendored
View File

@ -13,3 +13,5 @@
.externalNativeBuild
.cxx
local.properties
apikeys.properties

20
README.md Normal file
View File

@ -0,0 +1,20 @@
# How Long To Beat Demo App
## What's this?
See https://kuleuven-diepenbeek.github.io/appdev-course/extra/demo/
## Building
### Create apikeys.properties
Add the following to a new file called `apikeys.properties` (in the root folder):
```
GoogleVisionApiKey="yourkey"
```
Your Google Vision API Key can be fetched through [console.cloud.google.com](https://console.cloud.google.com).
The key `GoogleVisionApiKey` is used in `build.gradle.kts` of the app and auto-generates the correct string in the class `BuildConfig`, ready to be used in the Kotlin/Java code. See The app Gradle build file for more information.

View File

@ -5,6 +5,11 @@ plugins {
kotlin("plugin.serialization") version "1.5.21"
}
val apiKeys = file("../apikeys.properties").readLines().map {
val keyvalues = it.split("=")
keyvalues[0] to keyvalues[1]
}.toMap()
android {
compileSdk = 31
buildToolsVersion = "31.0.0"
@ -28,6 +33,13 @@ android {
)
}
}
buildTypes.forEach {
// Will crash and do weird things if you did not provide your key.
// See README.md for more information.
it.buildConfigField("String", "GOOGLE_VISION_API_KEY", apiKeys["GoogleVisionApiKey"]!!)
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
@ -43,6 +55,7 @@ android {
dependencies {
// --- defaults
implementation("androidx.core:core-ktx:1.6.0")
implementation("androidx.activity:activity-ktx:1.3.1")
implementation("androidx.appcompat:appcompat:1.3.1")
implementation("com.google.android.material:material:1.4.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.0")
@ -51,12 +64,14 @@ dependencies {
androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
testImplementation("io.mockk:mockk:1.12.0")
// --- kotlinx extras
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1")
// --- navigation
// see https://developer.android.com/guide/navigation/navigation-getting-started
val nav_version = "2.3.5"
implementation("androidx.navigation:navigation-fragment:$nav_version")
implementation("androidx.navigation:navigation-ui:$nav_version")
@ -77,16 +92,7 @@ dependencies {
// --- Volley for HTTP requests
implementation("com.android.volley:volley:1.2.0")
// --- Google Vision API specific dependencies
// firebase specific
//implementation("com.google.firebase:firebase-bom:28.3.1")
// OAUth play services
//implementation("com.google.android.gms:play-services-auth:19.2.0")
//implementation("com.google.android.gms:play-services-base:17.6.0")
// Vision itself
// Google Vision
// comes with conflicts, exclude http client using https://docs.gradle.org/current/userguide/dependency_downgrade_and_exclude.html
implementation("com.google.api-client:google-api-client-android:1.32.1") {
exclude(module = "httpclient")

View File

@ -0,0 +1,35 @@
package be.kuleuven.howlongtobeat.cartridges
import androidx.test.platform.app.InstrumentationRegistry
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
class CartridgeFinderViaDuckDuckGoTest {
private lateinit var repo: CartridgeFinderViaDuckDuckGo
@Before
fun setUp() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
repo = CartridgeFinderViaDuckDuckGo(appContext)
// no need to set the main dispatcher, it's running on the device itself!
}
@Test
fun find_knownCodesForDuckDuckGo_returnsCorrectCartridge() = runBlocking {
launch(Dispatchers.Main) {
val smbDeluxe = repo.find("CGB-AHYE-USA")
assertEquals("super mario bros deluxe", smbDeluxe?.title)
assertEquals("CGB-AHYE-USA", smbDeluxe?.code)
val marioGolf = repo.find("cgb-awxp-eur-1")
assertEquals("mario golf", marioGolf?.title)
assertEquals("cgb-awxp-eur-1", marioGolf?.code)
}
println("launched main dispatcher") // this must be there: Kotlin-to-Java doesn't recognize the retval because of runBlocking.
}
}

View File

@ -0,0 +1,46 @@
package be.kuleuven.howlongtobeat.hltb
import androidx.test.platform.app.InstrumentationRegistry
import be.kuleuven.howlongtobeat.cartridges.Cartridge
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
class HLTBClientImplTest {
private lateinit var client: HLTBClientImpl
@Before
fun setUp() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
client = HLTBClientImpl(appContext)
}
@Test
fun find_unknownGame_returnsNull() = runBlocking {
launch(Dispatchers.Main) {
val results = client.find(Cartridge("type", "moesjamarramarra tis niet omdat ik wijs dat ge moet kijken he", "invalid"))
assertNull(results)
}
println("Dispatche launched")
}
@Test
fun find_someValidGame_retrievesSomeGamesBasedOnTitle() = runBlocking {
launch(Dispatchers.Main) {
val results = client.find(Cartridge("type", "Gex: Enter the Gecko", "CGB-GEX-666"))
assertEquals(1, results?.size)
val gex = results?.single()!!
assertEquals("Gex Enter the Gecko", gex.title)
assertEquals("CGB-GEX-666", gex.cartCode)
assertEquals(9.0, gex.howlong) // that long, huh.
}
println("Dispatche launched")
}
}

View File

@ -3,7 +3,7 @@ package be.kuleuven.howlongtobeat.model.room
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import be.kuleuven.howlongtobeat.model.Todo
import be.kuleuven.howlongtobeat.model.Game
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
@ -11,15 +11,15 @@ import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TodoPersistenceTests {
class GamePersistenceTests {
private lateinit var db: TodoDatabase
private lateinit var dao: TodoDao
private lateinit var db: GameDatabase
private lateinit var dao: GameDao
@Before
fun setUp() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(appContext, TodoDatabase::class.java)
db = Room.inMemoryDatabaseBuilder(appContext, GameDatabase::class.java)
.setQueryCallback(LogQueryCallBack(), CurrentThreadExecutor())
.build()
db.clearAllTables()
@ -33,20 +33,20 @@ class TodoPersistenceTests {
@Test
fun todoItemCanBePersisted() {
val item = Todo("brush my little pony", false)
val item = Game("brush my little pony","code", 0.0, false)
dao.insert(arrayListOf(item))
val refreshedItem = dao.query().single()
with(refreshedItem) {
assertEquals(item.title, title)
assertEquals(item.isDone, isDone)
assertEquals(item.finished, finished)
assertEquals(1, id)
}
}
@Test
fun updateUpdatesTodoPropertiesInDb() {
var todo = Todo("git good at Hollow Knight", false)
var todo = Game("git good at Hollow Knight", "code", 10.5, false)
dao.insert(arrayListOf(todo))
todo = dao.query().single() // refresh to get the ID, otherwise update() will update where ID = 0
@ -57,12 +57,12 @@ class TodoPersistenceTests {
assertEquals(1, itemsFromDb.size)
with(itemsFromDb.single()) {
assertEquals(todo.title, title)
assertEquals(true, isDone)
assertEquals(true, finished)
}
}
private fun finallyFinishHollowKnight(item: Todo) {
private fun finallyFinishHollowKnight(item: Game) {
println("Congrats! On to Demon Souls?")
item.check()
item.finish()
}
}

View File

@ -3,6 +3,7 @@
package="be.kuleuven.howlongtobeat">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" android:required="false" />
<application
android:allowBackup="true"
@ -20,6 +21,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- WHY? See https://medium.com/codex/how-to-use-the-android-activity-result-api-for-selecting-and-taking-images-5dbcc3e6324b -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>

View File

@ -293,6 +293,8 @@ DMG-MLA-1,Super Mario Land (World) (Rev 1),Entry #1,gekkio-1,https://gbhwdb.gekk
DMG-MLA-1,Super Mario Land (World) (Rev 1),Entry #2,gekkio-2,https://gbhwdb.gekkio.fi/cartridges/DMG-MLA-1/gekkio-2.html,gekkio,DMG-ML-ITA,20A,DMG-BEAN-02,I,6,Jun/1996,June/1996,1996,6,LH53517,DMG-MLA-1 S LH531720 JAPAN B1 9627 D,Sharp,Sharp,27/1996,Week 27/1996,1996,,27,MBC1B1,DMG MBC1B1 Nintendo S 9625 3 A,Sharp,Sharp,25/1996,Week 25/1996,1996,,25,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
DMG-MLA-1,Super Mario Land (World) (Rev 1),Entry #3,gekkio-3,https://gbhwdb.gekkio.fi/cartridges/DMG-MLA-1/gekkio-3.html,gekkio,DMG-MLA,22A,DMG-BEAN-02,B,5,Jul/1993,July/1993,1993,7,LH53514,DMG-MLA-1 S LH5314B2 JAPAN B1 9339 E,Sharp,Sharp,39/1993,Week 39/1993,1993,,39,MBC1B,DMG MBC1B Nintendo J9330BR,Motorola,Motorola,30/1993,Week 30/1993,1993,,30,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
DMG-MLA-1,Super Mario Land (World) (Rev 1),Entry #1,tobiasvl-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MLA-1/tobiasvl-1.html,tobiasvl,DMG-ML-USA-1,22A,DMG-BEAN-10,I,5,Jul/1998,July/1998,1998,7,LH53517,DMG-MLA-1 S LH531720 JAPAN B1 9836 D,Sharp,Sharp,36/1998,Week 36/1998,1998,,36,MBC1B,DMG MBC1-B Nintendo P 8'59,,,1998,1998,1998,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
DMG-MQE-0,"Super Mario Land 2 - 6 Golden Coins (Europe)",Entry #1,max-m-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-0/max-m-1.html,max-m,DMG-MQ-EUR,00,DMG-DECN-02,I,2,Sep/1992,September/1992,1992,9,LH534M,DMG-MQE-0 S LH534M02 JAPAN E1 9243 D,Sharp,Sharp,43/1992,Week 43/1992,1992,,43,MBC1B,Nintendo DMG MBC1B N 9221BA041,,,21/1992,Week 21/1992,1992,,21,LH5168NFB-10TL,LH5168NFB-10TL LSI LOGIC JAPAN D242 7 BC,LSI Logic,LSI Logic,42/1992,Week 42/1992,1992,,42,MM1026A,295 26A,Mitsumi,Mitsumi,1992,1992,1992,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
DMG-MQE-0,"Super Mario Land 2 - 6 Golden Coins (USA)",Entry #1,max-m-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-0/max-m-1.html,max-m,DMG-MO-USA,00,DMG-DECN-02,I,2,Sep/1992,September/1992,1992,9,LH534M,DMG-MQE-0 S LH534M02 JAPAN E1 9243 D,Sharp,Sharp,43/1992,Week 43/1992,1992,,43,MBC1B,Nintendo DMG MBC1B N 9221BA041,,,21/1992,Week 21/1992,1992,,21,LH5168NFB-10TL,LH5168NFB-10TL LSI LOGIC JAPAN D242 7 BC,LSI Logic,LSI Logic,42/1992,Week 42/1992,1992,,42,MM1026A,295 26A,Mitsumi,Mitsumi,1992,1992,1992,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
DMG-MQE-0,"Super Mario Land 2 - 6 Golden Coins (USA, Europe)",Entry #1,max-m-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-0/max-m-1.html,max-m,DMG-MQ-NOE,00,DMG-DECN-02,I,2,Sep/1992,September/1992,1992,9,LH534M,DMG-MQE-0 S LH534M02 JAPAN E1 9243 D,Sharp,Sharp,43/1992,Week 43/1992,1992,,43,MBC1B,Nintendo DMG MBC1B N 9221BA041,,,21/1992,Week 21/1992,1992,,21,LH5168NFB-10TL,LH5168NFB-10TL LSI LOGIC JAPAN D242 7 BC,LSI Logic,LSI Logic,42/1992,Week 42/1992,1992,,42,MM1026A,295 26A,Mitsumi,Mitsumi,1992,1992,1992,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
DMG-MQE-0,"Super Mario Land 2 - 6 Golden Coins (USA, Europe)",Entry #1,tobiasvl-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-0/tobiasvl-1.html,tobiasvl,DMG-MQ-UKV,23,DMG-DECN-02,I,2,Nov/1992,November/1992,1992,11,LH534M,DMG-MQE-0 S LH534M02 JAPAN E1 9248 D,Sharp,Sharp,48/1992,Week 48/1992,1992,,48,MBC1B,Nintendo DMG MBC1B N 9245BA035,,,45/1992,Week 45/1992,1992,,45,LH5168NFB-10TL,LH5168NFB-10TL LSI LOGIC JAPAN D244 7 BC,LSI Logic,LSI Logic,44/1992,Week 44/1992,1992,,44,MM1026A,2J5 26A,Mitsumi,Mitsumi,1992,1992,1992,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
DMG-MQE-2,"Super Mario Land 2 - 6 Golden Coins (USA, Europe) (Rev 2)",Entry #1,creeps-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-2/creeps-1.html,creeps,DMG-MQ-USA-1,20B,DMG-DECN-10,K,6,Mar/1998,March/1998,1998,3,N-4001EJGW,DMG-MQE-2 E1 N-4001EJGW-J08 9822E7026,,,22/1998,Week 22/1998,1998,,22,MBC1B1,DMG MBC1B1 Nintendo S 9816 5 A,Sharp,Sharp,16/1998,Week 16/1998,1998,,16,BR6265BF-10SL,BR6265BF-10SL 814 136N,ROHM,ROHM,14/1998,Week 14/1998,1998,,14,MM1026A,746 26A,Mitsumi,Mitsumi,1997,1997,1997,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,

1 type name title slug url contributor code stamp board_type board_circled_letters board_extra_label board_calendar_short board_calendar board_year board_month rom_kind rom_label rom_manufacturer rom_manufacturer_name rom_calendar_short rom_calendar rom_year rom_month rom_week mapper_kind mapper_label mapper_manufacturer mapper_manufacturer_name mapper_calendar_short mapper_calendar mapper_year mapper_month mapper_week ram_kind ram_label ram_manufacturer ram_manufacturer_name ram_calendar_short ram_calendar ram_year ram_month ram_week ram_protector_kind ram_protector_label ram_protector_manufacturer ram_protector_manufacturer_name ram_protector_calendar_short ram_protector_calendar ram_protector_year ram_protector_month ram_protector_week crystal_kind crystal_label crystal_manufacturer crystal_manufacturer_name crystal_calendar_short crystal_calendar crystal_year crystal_month crystal_week rom2_kind rom2_label rom2_manufacturer rom2_manufacturer_name rom2_calendar_short rom2_calendar rom2_year rom2_month rom2_week flash_kind flash_label flash_manufacturer flash_manufacturer_name flash_calendar_short flash_calendar flash_year flash_month flash_week line_decoder_kind line_decoder_label line_decoder_manufacturer line_decoder_manufacturer_name line_decoder_calendar_short line_decoder_calendar line_decoder_year line_decoder_month line_decoder_week eeprom_kind eeprom_label eeprom_manufacturer eeprom_manufacturer_name eeprom_calendar_short eeprom_calendar eeprom_year eeprom_month eeprom_week accelerometer_kind accelerometer_label accelerometer_manufacturer accelerometer_manufacturer_name accelerometer_calendar_short accelerometer_calendar accelerometer_year accelerometer_month accelerometer_week u4_kind u4_label u4_manufacturer u4_manufacturer_name u4_calendar_short u4_calendar u4_year u4_month u4_week u5_kind u5_label u5_manufacturer u5_manufacturer_name u5_calendar_short u5_calendar u5_year u5_month u5_week crystal
293 DMG-MLA-1 Super Mario Land (World) (Rev 1) Entry #2 gekkio-2 https://gbhwdb.gekkio.fi/cartridges/DMG-MLA-1/gekkio-2.html gekkio DMG-ML-ITA 20A DMG-BEAN-02 I 6 Jun/1996 June/1996 1996 6 LH53517 DMG-MLA-1 S LH531720 JAPAN B1 9627 D Sharp Sharp 27/1996 Week 27/1996 1996 27 MBC1B1 DMG MBC1B1 Nintendo S 9625 3 A Sharp Sharp 25/1996 Week 25/1996 1996 25
294 DMG-MLA-1 Super Mario Land (World) (Rev 1) Entry #3 gekkio-3 https://gbhwdb.gekkio.fi/cartridges/DMG-MLA-1/gekkio-3.html gekkio DMG-MLA 22A DMG-BEAN-02 B 5 Jul/1993 July/1993 1993 7 LH53514 DMG-MLA-1 S LH5314B2 JAPAN B1 9339 E Sharp Sharp 39/1993 Week 39/1993 1993 39 MBC1B DMG MBC1B Nintendo J9330BR Motorola Motorola 30/1993 Week 30/1993 1993 30
295 DMG-MLA-1 Super Mario Land (World) (Rev 1) Entry #1 tobiasvl-1 https://gbhwdb.gekkio.fi/cartridges/DMG-MLA-1/tobiasvl-1.html tobiasvl DMG-ML-USA-1 22A DMG-BEAN-10 I 5 Jul/1998 July/1998 1998 7 LH53517 DMG-MLA-1 S LH531720 JAPAN B1 9836 D Sharp Sharp 36/1998 Week 36/1998 1998 36 MBC1B DMG MBC1-B Nintendo P 8'59 1998 1998 1998
296 DMG-MQE-0 Super Mario Land 2 - 6 Golden Coins (Europe) Entry #1 max-m-1 https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-0/max-m-1.html max-m DMG-MQ-EUR 00 DMG-DECN-02 I 2 Sep/1992 September/1992 1992 9 LH534M DMG-MQE-0 S LH534M02 JAPAN E1 9243 D Sharp Sharp 43/1992 Week 43/1992 1992 43 MBC1B Nintendo DMG MBC1B N 9221BA041 21/1992 Week 21/1992 1992 21 LH5168NFB-10TL LH5168NFB-10TL LSI LOGIC JAPAN D242 7 BC LSI Logic LSI Logic 42/1992 Week 42/1992 1992 42 MM1026A 295 26A Mitsumi Mitsumi 1992 1992 1992
297 DMG-MQE-0 Super Mario Land 2 - 6 Golden Coins (USA) Entry #1 max-m-1 https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-0/max-m-1.html max-m DMG-MO-USA 00 DMG-DECN-02 I 2 Sep/1992 September/1992 1992 9 LH534M DMG-MQE-0 S LH534M02 JAPAN E1 9243 D Sharp Sharp 43/1992 Week 43/1992 1992 43 MBC1B Nintendo DMG MBC1B N 9221BA041 21/1992 Week 21/1992 1992 21 LH5168NFB-10TL LH5168NFB-10TL LSI LOGIC JAPAN D242 7 BC LSI Logic LSI Logic 42/1992 Week 42/1992 1992 42 MM1026A 295 26A Mitsumi Mitsumi 1992 1992 1992
298 DMG-MQE-0 Super Mario Land 2 - 6 Golden Coins (USA, Europe) Entry #1 max-m-1 https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-0/max-m-1.html max-m DMG-MQ-NOE 00 DMG-DECN-02 I 2 Sep/1992 September/1992 1992 9 LH534M DMG-MQE-0 S LH534M02 JAPAN E1 9243 D Sharp Sharp 43/1992 Week 43/1992 1992 43 MBC1B Nintendo DMG MBC1B N 9221BA041 21/1992 Week 21/1992 1992 21 LH5168NFB-10TL LH5168NFB-10TL LSI LOGIC JAPAN D242 7 BC LSI Logic LSI Logic 42/1992 Week 42/1992 1992 42 MM1026A 295 26A Mitsumi Mitsumi 1992 1992 1992
299 DMG-MQE-0 Super Mario Land 2 - 6 Golden Coins (USA, Europe) Entry #1 tobiasvl-1 https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-0/tobiasvl-1.html tobiasvl DMG-MQ-UKV 23 DMG-DECN-02 I 2 Nov/1992 November/1992 1992 11 LH534M DMG-MQE-0 S LH534M02 JAPAN E1 9248 D Sharp Sharp 48/1992 Week 48/1992 1992 48 MBC1B Nintendo DMG MBC1B N 9245BA035 45/1992 Week 45/1992 1992 45 LH5168NFB-10TL LH5168NFB-10TL LSI LOGIC JAPAN D244 7 BC LSI Logic LSI Logic 44/1992 Week 44/1992 1992 44 MM1026A 2J5 26A Mitsumi Mitsumi 1992 1992 1992
300 DMG-MQE-2 Super Mario Land 2 - 6 Golden Coins (USA, Europe) (Rev 2) Entry #1 creeps-1 https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-2/creeps-1.html creeps DMG-MQ-USA-1 20B DMG-DECN-10 K 6 Mar/1998 March/1998 1998 3 N-4001EJGW DMG-MQE-2 E1 N-4001EJGW-J08 9822E7026 22/1998 Week 22/1998 1998 22 MBC1B1 DMG MBC1B1 Nintendo S 9816 5 A Sharp Sharp 16/1998 Week 16/1998 1998 16 BR6265BF-10SL BR6265BF-10SL 814 136N ROHM ROHM 14/1998 Week 14/1998 1998 14 MM1026A 746 26A Mitsumi Mitsumi 1997 1997 1997

View File

@ -0,0 +1,39 @@
package be.kuleuven.howlongtobeat
import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.view.View
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import java.net.URL
import kotlin.math.roundToInt
fun Fragment.requireContentResolver(): ContentResolver = requireContext().contentResolver
fun Uri.toBitmap(contentResolver: ContentResolver): Bitmap
= BitmapFactory.decodeStream(contentResolver.openInputStream(this))
fun URL.downloadAsImage(): Bitmap =
BitmapFactory.decodeStream(this.openConnection().getInputStream())
fun Bitmap.save(location: String, context: Context) {
context.openFileOutput(location, Context.MODE_PRIVATE).use {
compress(Bitmap.CompressFormat.JPEG, 85, it)
}
}
fun Bitmap.scaleToWidth(width: Int): Bitmap {
val aspectRatio = this.width.toFloat() / this.height.toFloat()
val height = (width / aspectRatio).roundToInt()
return Bitmap.createScaledBitmap(this, width, height, false)
}
fun View.ensureVisible() {
if(!this.isVisible) {
this.visibility = View.VISIBLE
}
}

View File

@ -0,0 +1,49 @@
package be.kuleuven.howlongtobeat
import android.graphics.BitmapFactory
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import be.kuleuven.howlongtobeat.databinding.FragmentGamedetailBinding
import be.kuleuven.howlongtobeat.model.Game
import be.kuleuven.howlongtobeat.model.GameRepository
import com.google.android.material.snackbar.Snackbar
class GameDetailFragment : Fragment(R.layout.fragment_gamedetail) {
private lateinit var binding: FragmentGamedetailBinding
private lateinit var gameRepo: GameRepository
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentGamedetailBinding.inflate(layoutInflater)
gameRepo = GameRepository.defaultImpl(requireContext())
val gameId = arguments?.getSerializable(Game.GAME_ID).toString().toInt()
val game = gameRepo.find(gameId)
binding.txtDetailTitle.text = "${game.title}\n${game.cartCode}"
binding.txtDetailGameStats.text = "${game.howLongToBeat} hr(s) to beat"
binding.chkDetailFinished.isChecked = game.finished
binding.chkDetailFinished.setOnClickListener {
game.finished = binding.chkDetailFinished.isChecked
gameRepo.update(game)
Snackbar.make(binding.root, "Saved!", Snackbar.LENGTH_SHORT).show()
}
requireContext().openFileInput(game.boxartFileName).use {
binding.imgDetailBoxArt.setImageBitmap(BitmapFactory.decodeStream(it))
}
requireContext().openFileInput(game.snapshotFileName).use {
binding.imgDetailSnapshot.setImageBitmap(BitmapFactory.decodeStream(it))
}
return binding.root
}
}

View File

@ -0,0 +1,43 @@
package be.kuleuven.howlongtobeat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.TextView
import androidx.fragment.app.findFragment
import androidx.recyclerview.widget.RecyclerView
import be.kuleuven.howlongtobeat.model.Game
class GameListAdapter(val items: List<Game>) : RecyclerView.Adapter<GameListAdapter.GameListViewHolder>() {
private lateinit var parentFragment: GameListFragment
inner class GameListViewHolder(currentItemView: View) : RecyclerView.ViewHolder(currentItemView)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameListViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_game, parent, false)
parentFragment = parent.findFragment()
return GameListViewHolder(view)
}
override fun onBindViewHolder(holder: GameListViewHolder, position: Int) {
val game = items[position]
holder.itemView.apply {
setOnLongClickListener {
parentFragment.selectGame(game)
true
}
val chkFinished = findViewById<CheckBox>(R.id.chkGameFinished)
findViewById<TextView>(R.id.txtTodoTitle).text = game.title
chkFinished.isChecked = game.finished
chkFinished.setOnClickListener {
game.finished = chkFinished.isChecked
}
}
}
override fun getItemCount(): Int = items.size
}

View File

@ -0,0 +1,63 @@
package be.kuleuven.howlongtobeat
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import be.kuleuven.howlongtobeat.databinding.FragmentGamelistBinding
import be.kuleuven.howlongtobeat.model.Game
import be.kuleuven.howlongtobeat.model.GameRepository
class GameListFragment : Fragment(R.layout.fragment_gamelist) {
private val gameList = arrayListOf<Game>()
private lateinit var binding: FragmentGamelistBinding
private lateinit var main: MainActivity
private lateinit var adapter: GameListAdapter
private lateinit var gameRepository: GameRepository
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentGamelistBinding.inflate(layoutInflater)
main = activity as MainActivity
gameRepository = GameRepository.defaultImpl(main.applicationContext)
loadGames()
adapter = GameListAdapter(gameList)
binding.rvGameList.adapter = adapter
binding.rvGameList.layoutManager = LinearLayoutManager(this.context)
binding.btnAddTodo.setOnClickListener {
findNavController().navigate(R.id.action_gameListFragment_to_loadingFragment)
}
return binding.root
}
private fun loadGames() {
gameList.clear()
gameList.addAll(gameRepository.load())
if(!gameList.any()) {
gameList.add(Game.NONE_YET)
}
}
fun selectGame(game: Game) {
findNavController().navigate(R.id.action_gameListFragment_to_gameDetailFragment, bundleOf(Game.GAME_ID to game.id.toString()))
}
fun clearAllItems() {
gameList.clear()
gameList.add(Game.NONE_YET)
adapter.notifyDataSetChanged()
}
}

View File

@ -0,0 +1,60 @@
package be.kuleuven.howlongtobeat
import android.graphics.BitmapFactory
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.findFragment
import androidx.recyclerview.widget.RecyclerView
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class HltbResultsAdapter(private val items: List<HowLongToBeatResult>) : RecyclerView.Adapter<HltbResultsAdapter.HltbResultsViewHolder>() {
inner class HltbResultsViewHolder(currentItemView: View) : RecyclerView.ViewHolder(currentItemView)
private lateinit var parentFragment: HltbResultsFragment
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HltbResultsViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_hltbresult, parent, false)
parentFragment = parent.findFragment()
return HltbResultsViewHolder(view)
}
override fun onBindViewHolder(holder: HltbResultsViewHolder, position: Int) {
val itm = items[position]
holder.itemView.apply {
var art = BitmapFactory.decodeResource(resources, R.drawable.emptygb)
setOnLongClickListener {
parentFragment.addResultToGameLibrary(itm, art)
true
}
findViewById<TextView>(R.id.txtHltbItemResult).text = itm.toString()
val boxArtView = findViewById<ImageView>(R.id.imgHltbItemResult)
if(itm.hasBoxart()) {
MainScope().launch{
withContext(Dispatchers.IO) {
art = itm.boxartUrl().downloadAsImage()
}
withContext(Dispatchers.Main) {
boxArtView.setImageBitmap(art)
}
}
} else {
boxArtView.setImageBitmap(art)
}
}
}
override fun getItemCount(): Int = items.size
}

View File

@ -0,0 +1,63 @@
package be.kuleuven.howlongtobeat
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import be.kuleuven.howlongtobeat.databinding.FragmentHltbresultsBinding
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
import be.kuleuven.howlongtobeat.model.Game
import be.kuleuven.howlongtobeat.model.GameRepository
import com.google.android.material.snackbar.Snackbar
import java.io.FileInputStream
class HltbResultsFragment : Fragment(R.layout.fragment_hltbresults) {
private lateinit var cachedSnapshotPath: Uri
private lateinit var binding: FragmentHltbresultsBinding
private lateinit var adapter: HltbResultsAdapter
private lateinit var gameRepository: GameRepository
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentHltbresultsBinding.inflate(layoutInflater)
val resultFromLoadingFragment = arguments?.getSerializable(HowLongToBeatResult.RESULT) as List<HowLongToBeatResult>
cachedSnapshotPath = Uri.parse(arguments?.getSerializable(HowLongToBeatResult.SNAPSHOT_URI) as String)
gameRepository = GameRepository.defaultImpl(requireContext())
adapter = HltbResultsAdapter(resultFromLoadingFragment)
binding.rvHltbResult.adapter = adapter
binding.rvHltbResult.layoutManager = LinearLayoutManager(this.context)
return binding.root
}
fun addResultToGameLibrary(hltbResult: HowLongToBeatResult, downloadedBoxart: Bitmap) {
val game = Game(hltbResult)
downloadedBoxart.save(game.boxartFileName, requireContext())
copyCachedSnapshotTo(game.snapshotFileName)
gameRepository.save(game)
Snackbar.make(requireView(), "Added ${hltbResult.title} to library!", Snackbar.LENGTH_LONG).show()
findNavController().navigate(R.id.action_hltbResultsFragment_to_gameListFragment)
}
private fun copyCachedSnapshotTo(destination: String) {
val inStream = requireContext().contentResolver.openInputStream(cachedSnapshotPath) as FileInputStream
inStream.use {
requireContext().openFileOutput(destination, Context.MODE_PRIVATE).use { outStream ->
inStream.channel.transferTo(0, inStream.channel.size(), outStream.channel)
}
}
}
}

View File

@ -0,0 +1,7 @@
package be.kuleuven.howlongtobeat
import android.graphics.Bitmap
interface ImageRecognizer {
suspend fun recognizeCartCode(image: Bitmap): String?
}

View File

@ -1,5 +1,150 @@
package be.kuleuven.howlongtobeat
import android.Manifest
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider
import androidx.core.content.PermissionChecker
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import be.kuleuven.howlongtobeat.databinding.FragmentLoadingBinding
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
import be.kuleuven.howlongtobeat.model.GameFinder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import java.io.File
class LoadingFragment : Fragment(R.layout.fragment_loading)
class LoadingFragment : Fragment(R.layout.fragment_loading) {
private lateinit var cameraPermissionActivityResult: ActivityResultLauncher<String>
private lateinit var cameraActivityResult: ActivityResultLauncher<Uri>
private lateinit var main: MainActivity
private lateinit var binding: FragmentLoadingBinding
private var snapshot: Uri? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentLoadingBinding.inflate(layoutInflater)
main = activity as MainActivity
cameraActivityResult = registerForActivityResult(ActivityResultContracts.TakePicture(), this::cameraSnapTaken)
cameraPermissionActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission(), this::cameraPermissionAcceptedOrDenied)
binding.btnRetryAfterLoading.setOnClickListener {
tryToMakeCameraSnap()
}
return binding.root
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
val inProgress = savedInstanceState?.getBoolean("inprogress") ?: false
if(!inProgress) {
// Don't do this in onCreateView, things go awry if you rotate the smartphone!
tryToMakeCameraSnap()
}
super.onViewStateRestored(savedInstanceState)
}
override fun onSaveInstanceState(outState: Bundle) {
outState.run {
putBoolean("inprogress", snapshot != null)
}
super.onSaveInstanceState(outState)
}
private fun cameraSnapTaken(succeeded: Boolean) {
if(!succeeded || snapshot == null) {
errorInProgress("Photo could not be saved, try again?")
return
}
progress("Scaling image for upload...")
val bitmap = snapshot!!.toBitmap(requireContentResolver()).scaleToWidth(1600)
MainScope().launch{
try {
findGameBasedOnCameraSnap(bitmap)
} catch (errorDuringFind: UnableToFindGameException) {
errorInProgress("${errorDuringFind.message}\nRetry?")
}
}
}
private suspend fun findGameBasedOnCameraSnap(pic: Bitmap) {
var picToAnalyze = pic
// Uncomment this line if you want to stub out camera pictures
// picToAnalyze = BitmapFactory.decodeResource(resources, R.drawable.sml2)
val hltbResults = GameFinder.default(main.applicationContext).findGameBasedOnCameraSnap(picToAnalyze) {
progress(it)
}
Snackbar.make(requireView(), "Found ${hltbResults.size} game(s)", Snackbar.LENGTH_LONG).show()
val bundle = bundleOf(
HowLongToBeatResult.RESULT to hltbResults,
HowLongToBeatResult.SNAPSHOT_URI to snapshot.toString()
)
findNavController().navigate(R.id.action_loadingFragment_to_hltbResultsFragment, bundle)
}
private fun cameraPermissionAcceptedOrDenied(succeeded: Boolean) {
if(succeeded) {
makeCameraSnap()
} else {
errorInProgress("Camera permission required!")
}
}
private fun tryToMakeCameraSnap() {
binding.btnRetryAfterLoading.hide()
progress("Making snapshot with camera...")
if(PermissionChecker.checkSelfPermission(main, Manifest.permission.CAMERA) != PermissionChecker.PERMISSION_GRANTED) {
cameraPermissionActivityResult.launch(Manifest.permission.CAMERA)
} else {
makeCameraSnap()
}
}
private fun createNewTempCameraFile() {
// a <Provider/> should be present in the manifest file.
val tempFile = File.createTempFile("hltbCameraSnap", ".png", main.cacheDir).apply {
createNewFile()
deleteOnExit()
}
snapshot = FileProvider.getUriForFile(main.applicationContext, "${BuildConfig.APPLICATION_ID}.provider", tempFile)
}
private fun makeCameraSnap() {
createNewTempCameraFile()
cameraActivityResult.launch(snapshot)
}
private fun progress(msg: String) {
binding.indeterminateBar.ensureVisible()
binding.txtLoading.text = msg
}
private fun errorInProgress(msg: String) {
snapshot = null
progress(msg)
binding.indeterminateBar.visibility = View.GONE
Snackbar.make(requireView(), msg, Snackbar.LENGTH_LONG).show()
binding.btnRetryAfterLoading.show()
}
}

View File

@ -1,109 +1,102 @@
package be.kuleuven.howlongtobeat
import android.graphics.BitmapFactory
import android.app.AlertDialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepository
import androidx.core.view.GravityCompat
import androidx.navigation.fragment.NavHostFragment
import be.kuleuven.howlongtobeat.databinding.ActivityMainBinding
import be.kuleuven.howlongtobeat.google.GoogleVisionClient
import be.kuleuven.howlongtobeat.hltb.HLTBClient
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import be.kuleuven.howlongtobeat.model.GameRepository
import kotlin.math.roundToInt
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var menuBarToggle: ActionBarDrawerToggle
private var todoFragment = TodoFragment()
private var loadingFragment = LoadingFragment()
private lateinit var hltbClient: HLTBClient
private lateinit var gameRepository: GameRepository
private val navHostFragment: NavHostFragment
get() = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setCurrentFragment(loadingFragment)
gameRepository = GameRepository.defaultImpl(applicationContext)
setupMenuDrawer()
hltbClient = HLTBClient(applicationContext) {
todoFragment.onHltbGamesRetrieved(it)
}
val cartRepo = CartridgesRepository.fromAsset(applicationContext)
val sml2SampleData = BitmapFactory.decodeResource(resources, R.drawable.supermarioland2)
MainScope().launch{
val cartCode = GoogleVisionClient().findCartCodeViaGoogleVision(sml2SampleData)
val foundCart = cartRepo.find(cartCode)
withContext(Dispatchers.Main) {
Snackbar.make(binding.root, "Found cart: ${foundCart.title}!", Snackbar.LENGTH_LONG).show()
}
setCurrentFragment(todoFragment)
hltbClient.triggerFind(foundCart.title)
}
setContentView(binding.root)
}
private fun setCurrentFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction().apply {
replace(R.id.frmTodoContainer, fragment)
commit()
}
}
private fun setupMenuDrawer() {
menuBarToggle = ActionBarDrawerToggle(this, binding.drawerLayout, R.string.menu_open, R.string.menu_close)
binding.drawerLayout.addDrawerListener(menuBarToggle)
// it's now ready to be used
menuBarToggle.syncState()
// when the menu drawer opens, the toggle button moves to a "back" button and it will close again.
supportActionBar?.setDisplayHomeAsUpEnabled(true)
// handle menu drawer item clicks.
// since these are all events that influence the fragment list, delegate their actions!
binding.navView.setNavigationItemSelectedListener {
when (it.itemId) {
R.id.mnuClear -> clearAllItems()
R.id.mnuClearLatest -> clearLatestItem()
R.id.mnuReset -> resetItems()
R.id.mnuClear -> tryToClearAllItems()
R.id.mnuStats -> showStats()
}
true
}
}
private fun clearAllItems() {
todoFragment.clearAllItems()
gameRepository.overwrite(listOf())
val currentActiveFragment = navHostFragment.childFragmentManager.fragments[0] as GameListFragment
currentActiveFragment.clearAllItems()
binding.drawerLayout.closeDrawer(GravityCompat.START)
}
private fun clearLatestItem() {
todoFragment.clearLatestItem()
private fun tryToClearAllItems() {
AlertDialog.Builder(this)
.setTitle("Delete all games from the DB")
.setMessage("Are you sure?")
.setIcon(android.R.drawable.ic_dialog_alert)
.setPositiveButton("Yup") { dialog, _ ->
clearAllItems()
dialog.dismiss()
}
.setNegativeButton("Nah") { dialog, _ ->
close(dialog)
}
.create()
.show()
}
private fun resetItems() {
todoFragment.resetItems()
private fun close(dialog: DialogInterface) {
dialog.dismiss()
binding.drawerLayout.closeDrawer(GravityCompat.START)
}
private fun showStats() {
val allGames = gameRepository.load()
val hoursToBeat = allGames.filter { !it.finished }.map { it.howLongToBeat }.sum()
val hoursAlreadyBeat = allGames.filter { it.finished }.map { it.howLongToBeat }.sum()
val percCompleted = if(hoursAlreadyBeat > 0) ((hoursToBeat / hoursAlreadyBeat) * 100).roundToInt() else 0
AlertDialog.Builder(this)
.setTitle("Game library stats")
.setMessage("Total games: ${allGames.size}\nHours still to beat: ${hoursToBeat}\nHours already beat: ${hoursAlreadyBeat}\n\n$percCompleted% total completed.")
.setIcon(android.R.drawable.ic_dialog_info)
.setNeutralButton("Nice!") { dialog, _ ->
close(dialog)
}
.create()
.show()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
// we need to do this to respond correctly to clicks on menu items, otherwise it won't be caught
if(menuBarToggle.onOptionsItemSelected(item)) {
return true
}
return super.onOptionsItemSelected(item)
}
fun hideKeyboard(view: View) {
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
}
}

View File

@ -1,37 +0,0 @@
package be.kuleuven.howlongtobeat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import be.kuleuven.howlongtobeat.model.Todo
class TodoAdapter(val items: List<Todo>) : RecyclerView.Adapter<TodoAdapter.TodoViewHolder>() {
inner class TodoViewHolder(currentItemView: View) : RecyclerView.ViewHolder(currentItemView)
// this creates the needed ViewHolder class that links our layout XML to our viewHolder
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoViewHolder {
// don't forget to set attachToRoot to false, otherwise it will crash!
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_todo, parent, false)
return TodoViewHolder(view)
}
override fun onBindViewHolder(holder: TodoViewHolder, position: Int) {
// bind the data to our items: set the todo text view text and checked state accordingly
val currentTodoItem = items[position]
holder.itemView.apply {
val checkBoxTodo = findViewById<CheckBox>(R.id.chkTodoDone)
findViewById<TextView>(R.id.txtTodoTitle).text = currentTodoItem.title
checkBoxTodo.isChecked = currentTodoItem.isDone
checkBoxTodo.setOnClickListener {
currentTodoItem.isDone = checkBoxTodo.isChecked
}
}
}
override fun getItemCount(): Int = items.size
}

View File

@ -1,78 +0,0 @@
package be.kuleuven.howlongtobeat
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import be.kuleuven.howlongtobeat.databinding.FragmentTodolistBinding
import be.kuleuven.howlongtobeat.hltb.Game
import be.kuleuven.howlongtobeat.hltb.HLTBClient
import be.kuleuven.howlongtobeat.model.Todo
class TodoFragment : Fragment(R.layout.fragment_todolist) {
private val todoList = arrayListOf<Todo>()
private lateinit var hltbClient: HLTBClient
private lateinit var binding: FragmentTodolistBinding
private lateinit var main: MainActivity
private lateinit var adapter: TodoAdapter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentTodolistBinding.inflate(layoutInflater)
main = activity as MainActivity
adapter = TodoAdapter(todoList)
binding.rvwTodo.adapter = adapter
// If we don't supply a layout manager, the recyclerview will not be displayed
// there are three options here: a simple LinearLayoutManager (1-dimensional), a GridLayoutManager (2D) or a StaggeredGridLayoutManager
binding.rvwTodo.layoutManager = LinearLayoutManager(this.context)
binding.btnAddTodo.setOnClickListener(this::addNewTotoItem)
return binding.root
}
fun onHltbGamesRetrieved(games: List<Game>) {
todoList.clear()
todoList.addAll(games.map { Todo("${it.title} (${it.howlong})", false) })
adapter.notifyDataSetChanged()
}
private fun addNewTotoItem(it: View) {
val newTodoTitle = binding.edtTodo.text.toString()
// this will not automatically updat the view!
todoList.add(Todo(newTodoTitle, false))
adapter.notifyItemInserted(todoList.size - 1)
// adapter.notifyDatasetChanged() also works but will update EVERYTHING, which is not too efficient.
binding.edtTodo.text.clear()
binding.edtTodo.clearFocus()
main.hideKeyboard(it)
}
fun clearAllItems() {
todoList.clear()
adapter.notifyDataSetChanged()
}
fun clearLatestItem() {
if(todoList.size >= 1) {
todoList.removeAt(todoList.size - 1)
adapter.notifyItemRemoved(todoList.size - 1)
}
}
fun resetItems() {
todoList.clear()
todoList.addAll(Todo.defaults())
adapter.notifyDataSetChanged()
}
}

View File

@ -0,0 +1,3 @@
package be.kuleuven.howlongtobeat
class UnableToFindGameException(message: String?) : Throwable(message)

View File

@ -5,8 +5,11 @@ data class Cartridge(val type: String, val name: String, val code: String) {
companion object {
val bracketRe = """\(.+\)""".toRegex()
val UNKNOWN_CART = Cartridge("", "UNKNOWN CART", "DMG-???")
val KNOWN_CART_PREFIXES = listOf("DMG", "CGB")
fun isValid(code: String): Boolean = code.startsWith("DMG-") || code.startsWith("CGB-")
fun isValid(code: String): Boolean =
code != "" && !code.contains("\n") &&
KNOWN_CART_PREFIXES.any { code.startsWith("$it-") }
}
val title = bracketRe.replace(name, "").replace("-", "").trim()

View File

@ -0,0 +1,33 @@
package be.kuleuven.howlongtobeat.cartridges
import android.content.Context
import com.android.volley.Response
import com.android.volley.toolbox.StringRequest
import com.android.volley.toolbox.Volley
import kotlin.coroutines.suspendCoroutine
class CartridgeFinderViaDuckDuckGo(private val context: Context) : CartridgesRepository {
class CartridgeFinderViaDuckDuckGoRequest(query: String, responseListener: Response.Listener<String>) :
StringRequest(
Method.GET, "https://html.duckduckgo.com/html/?q=${query}", responseListener,
Response.ErrorListener {
println("Something went wrong: ${it.message}")
}) {
override fun getHeaders(): MutableMap<String, String> {
return hashMapOf("User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:90.0) Gecko/20100101 Firefox/90.0")
}
}
override suspend fun find(code: String?): Cartridge? = suspendCoroutine { cont ->
if(code == null) {
cont.resumeWith(Result.success(null))
} else {
val queue = Volley.newRequestQueue(context)
queue.add(CartridgeFinderViaDuckDuckGoRequest(code) { html ->
cont.resumeWith(Result.success(DuckDuckGoResultParser.parse(html, code)))
})
}
}
}

View File

@ -1,38 +1,13 @@
package be.kuleuven.howlongtobeat.cartridges
import android.content.Context
import java.io.InputStream
/**
* Reads cartridges.csv export from https://gbhwdb.gekkio.fi/cartridges/
* Headers: type,name,title,slug,url,contributor,code,...
* E.g.: DMG-MQE-2,"Super Mario Land 2 - 6 Golden Coins (USA, Europe) (Rev 2)",Entry #1,creeps-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-2/creeps-1.html,creeps,DMG-MQ-USA-1,
*/
class CartridgesRepository(csvStream: InputStream) {
companion object {
fun fromAsset(context: Context): CartridgesRepository =
CartridgesRepository(context.assets.open("cartridges.csv"))
}
val cartridges: List<Cartridge> =
csvStream.bufferedReader().use {
it.readLines()
}.filter {
!it.startsWith("type,name,title,")
}.map {
val data = it.split(",(?=([^\"]*\"[^\"]*\")*[^\"]*$)".toRegex())
Cartridge(data[0].replace("\"", "").trim(), data[1].replace("\"", "").trim(), data[6].replace("\"", "").trim())
}.filter { it.code != "" }
.toList()
fun find(code: String?): Cartridge {
if(code == null) return Cartridge.UNKNOWN_CART
val possiblyFound = cartridges.filter {
it.code == code || it.code.contains(code) || code.contains(it.code)
}.firstOrNull()
return possiblyFound ?: Cartridge.UNKNOWN_CART
}
interface CartridgesRepository {
suspend fun find(code: String?): Cartridge?
}
suspend fun findFirstCartridgeForRepos(code: String?, repos: List<CartridgesRepository>): Cartridge? {
for(repo in repos) {
val result = repo.find(code)
if(result != null) return result
}
return null
}

View File

@ -0,0 +1,41 @@
package be.kuleuven.howlongtobeat.cartridges
import android.content.Context
import java.io.InputStream
import kotlin.coroutines.suspendCoroutine
/**
* Reads cartridges.csv export from https://gbhwdb.gekkio.fi/cartridges/
* Headers: type,name,title,slug,url,contributor,code,...
* E.g.: DMG-MQE-2,"Super Mario Land 2 - 6 Golden Coins (USA, Europe) (Rev 2)",Entry #1,creeps-1,https://gbhwdb.gekkio.fi/cartridges/DMG-MQE-2/creeps-1.html,creeps,DMG-MQ-USA-1,
*/
class CartridgesRepositoryGekkioFi(csvStream: InputStream) : CartridgesRepository {
companion object {
fun fromAsset(context: Context): CartridgesRepositoryGekkioFi =
CartridgesRepositoryGekkioFi(context.assets.open("cartridges.csv"))
}
val cartridges: List<Cartridge> =
csvStream.bufferedReader().use {
it.readLines()
}.filter {
!it.startsWith("type,name,title,")
}.map {
val data = it.split(",(?=([^\"]*\"[^\"]*\")*[^\"]*$)".toRegex())
Cartridge(data[0].replace("\"", "").trim(), data[1].replace("\"", "").trim(), data[6].replace("\"", "").trim())
}.filter { it.code != "" }
.toList()
override suspend fun find(code: String?): Cartridge? = suspendCoroutine { cont ->
if(code == null) {
cont.resumeWith(Result.success(null))
} else {
val possiblyFound = cartridges.filter {
it.code == code || it.code.contains(code) || code.contains(it.code)
}.firstOrNull()
cont.resumeWith(Result.success(possiblyFound))
}
}
}

View File

@ -0,0 +1,44 @@
package be.kuleuven.howlongtobeat.cartridges
object DuckDuckGoResultParser {
private val resultMatcher = """<a rel=".+" class="result__a" href=".+">(.+)</a>""".toRegex()
private val specialCharsToRemove = listOf(
"|",
".",
"-",
"/",
"\n",
",",
"!",
"Get information and compare prices of",
"for Game Boy",
"Release Information",
"Release Dates",
"Mobygames",
"Nintendo",
"Game Boy Advance",
"Game Boy color",
"Game Boy",
"GameBoy",
"Game ",
"GBC",
"GBA",
"VGDb",
"ebay"
)
fun parse(html: String, fromCode: String): Cartridge? {
// There are bound to be multiple results. Just fetch the first one as an educated guess
val matched = resultMatcher.find(html) ?: return null
var match = matched.groupValues[1]
.lowercase()
.replace(fromCode.lowercase(), "")
specialCharsToRemove.forEach {
match = match.replace(it.lowercase(), "")
}
return Cartridge("Unknown", match.trim(), fromCode)
}
}

View File

@ -1,6 +1,8 @@
package be.kuleuven.howlongtobeat.google
import android.graphics.Bitmap
import be.kuleuven.howlongtobeat.BuildConfig
import be.kuleuven.howlongtobeat.ImageRecognizer
import be.kuleuven.howlongtobeat.asEncodedGoogleVisionImage
import be.kuleuven.howlongtobeat.cartridges.Cartridge
import com.google.api.client.http.javanet.NetHttpTransport
@ -14,20 +16,15 @@ import com.google.api.services.vision.v1.model.Feature
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class GoogleVisionClient {
class GoogleVisionClient : ImageRecognizer {
// TODO encrypt and store externally: https://cloud.google.com/docs/authentication/api-keys?hl=en&visit_id=637642790375688006-1838986332&rd=1
private val vision = Vision.Builder(NetHttpTransport(), GsonFactory.getDefaultInstance(), null)
.setVisionRequestInitializer(VisionRequestInitializer("AIzaSyCaMjQQOY7508y95riDhr25fsrqe3m2JW0"))
.build()
private suspend fun findCartCodeViaGoogleVision(cameraSnap: Bitmap): String? {
val vision = buildVisionClient()
suspend fun findCartCodeViaGoogleVision(cameraSnap: Bitmap): String? {
var response: BatchAnnotateImagesResponse
withContext(Dispatchers.IO) {
println("Encoding image...")
val sml2Data = cameraSnap.asEncodedGoogleVisionImage()
println("Done, uploading image...")
val req = AnnotateImageRequest().apply {
features = listOf(Feature().apply {
type = "TEXT_DETECTION"
@ -41,13 +38,26 @@ class GoogleVisionClient {
}
response = vision.images().annotate(batch).execute()
}
if(response.responses.isEmpty()) {
if(response.responses.isEmpty()
|| response.responses.get(0).textAnnotations == null
|| response.responses.get(0).textAnnotations.isEmpty()) {
return null
}
val gbId = response.responses.get(0).textAnnotations.filter {
Cartridge.isValid(it.description)
}.firstOrNull()
return gbId?.description ?: null
return gbId?.description
}
}
private fun buildVisionClient(): Vision {
assert(BuildConfig.GOOGLE_VISION_API_KEY.length > 1)
return Vision.Builder(NetHttpTransport(), GsonFactory.getDefaultInstance(), null)
.setVisionRequestInitializer(VisionRequestInitializer(BuildConfig.GOOGLE_VISION_API_KEY))
.setApplicationName("How Long To Beat")
.build()
}
override suspend fun recognizeCartCode(image: Bitmap): String? = findCartCodeViaGoogleVision(image)
}

View File

@ -1,4 +0,0 @@
package be.kuleuven.howlongtobeat.hltb
data class Game(val title: String, val howlong: Double, val finished: Boolean = false) {
}

View File

@ -1,47 +1,7 @@
package be.kuleuven.howlongtobeat.hltb
import android.content.Context
import com.android.volley.Response
import com.android.volley.toolbox.StringRequest
import com.android.volley.toolbox.Volley
import be.kuleuven.howlongtobeat.cartridges.Cartridge
class HLTBClient(val context: Context, val onResponseFetched: (List<Game>) -> Unit) {
// Inspired by https://www.npmjs.com/package/howlongtobeat
// The API is abysmal, but hey, it works...
class HLTBRequest(val query: String, responseListener: Response.Listener<String>) :
StringRequest(Method.POST, "https://howlongtobeat.com/search_results.php?page=1", responseListener,
Response.ErrorListener {
println("Something went wrong: ${it.message}")
}) {
override fun getBodyContentType(): String {
return "application/x-www-form-urlencoded"
}
override fun getParams(): MutableMap<String, String> {
return hashMapOf(
"queryString" to query,
"t" to "games",
"sorthead" to "popular",
"sortd" to "0",
"plat" to "",
"length_type" to "main",
"length_min" to "",
"length_max" to "",
"detail" to "0"
)
}
override fun getHeaders(): MutableMap<String, String> {
return hashMapOf("User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:90.0) Gecko/20100101 Firefox/90.0")
}
}
fun triggerFind(query: String) {
val queue = Volley.newRequestQueue(context)
val req = HLTBRequest(query) {
onResponseFetched(HowLongToBeatResultParser.parse(it))
}
queue.add(req)
}
}
interface HLTBClient {
suspend fun find(cart: Cartridge): List<HowLongToBeatResult>?
}

View File

@ -0,0 +1,57 @@
package be.kuleuven.howlongtobeat.hltb
import android.content.Context
import be.kuleuven.howlongtobeat.cartridges.Cartridge
import com.android.volley.Response
import com.android.volley.toolbox.StringRequest
import com.android.volley.toolbox.Volley
import kotlin.coroutines.suspendCoroutine
class HLTBClientImpl(val context: Context) : HLTBClient {
companion object {
const val DOMAIN = "https://howlongtobeat.com"
}
// Inspired by https://www.npmjs.com/package/howlongtobeat
// The API is abysmal, but hey, it works...
class HLTBRequest(val query: String, responseListener: Response.Listener<String>) :
StringRequest(Method.POST, "$DOMAIN/search_results.php?page=1", responseListener,
Response.ErrorListener {
println("Something went wrong: ${it.message}")
}) {
override fun getBodyContentType(): String {
return "application/x-www-form-urlencoded"
}
override fun getParams(): MutableMap<String, String> {
return hashMapOf(
"queryString" to query,
"t" to "games",
"sorthead" to "popular",
"sortd" to "0",
"plat" to "",
"length_type" to "main",
"length_min" to "",
"length_max" to "",
"detail" to "0"
)
}
override fun getHeaders(): MutableMap<String, String> {
return hashMapOf(
"User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:90.0) Gecko/20100101 Firefox/90.0",
"Referer" to "https://howlongtobeat.com/"
)
}
}
override suspend fun find(cart: Cartridge): List<HowLongToBeatResult>? = suspendCoroutine { cont ->
val queue = Volley.newRequestQueue(context)
val req = HLTBRequest(cart.title) {
val hltbResults = HowLongToBeatResultParser.parse(it, cart)
cont.resumeWith(Result.success(hltbResults))
}
queue.add(req)
}
}

View File

@ -0,0 +1,16 @@
package be.kuleuven.howlongtobeat.hltb
import kotlinx.serialization.Serializable
import java.net.URL
@Serializable
data class HowLongToBeatResult(val title: String, val cartCode: String, val howlong: Double, val boxartUrl: String = "") : java.io.Serializable {
companion object {
const val RESULT = "HowLongToBeatResult"
const val SNAPSHOT_URI = "SnapshotUri"
}
fun hasBoxart(): Boolean = boxartUrl.startsWith(HLTBClientImpl.DOMAIN)
fun boxartUrl(): URL = URL(boxartUrl)
override fun toString(): String = "$title ($howlong hrs)"
}

View File

@ -1,24 +1,39 @@
package be.kuleuven.howlongtobeat.hltb
import be.kuleuven.howlongtobeat.cartridges.Cartridge
object HowLongToBeatResultParser {
private val titleMatcher = """<a class=".+" title="(.+)" href=""".toRegex()
private val hourMatcher = """<div class=".+">(.+) Hours""".toRegex()
private val boxArtMatcher = """<img alt=".+" src="(.+)"""".toRegex()
fun parse(html: String): List<Game> {
val result = arrayListOf<Game>()
fun parse(html: String, sourceCart: Cartridge): List<HowLongToBeatResult>? {
val result = arrayListOf<HowLongToBeatResult>()
val rows = html.split("\n")
for(i in 0..rows.size - 1) {
val matched = titleMatcher.find(rows[i])
if(matched != null) {
val (title) = matched.destructured
val hour = parseHoursFromRow(i, rows)
val boxart = parseBoxArtFromRow(i, rows)
result.add(Game(title, hour))
result.add(HowLongToBeatResult(title, sourceCart.code, hour, boxart))
}
}
return result
return if(result.any()) result else null
}
private fun parseBoxArtFromRow(row: Int, rows: List<String>): String {
// three rows up, there should be an image tag with the box art
if(row - 3 >= 0) {
val matchedBoxArt = boxArtMatcher.find(rows[row - 3])
if(matchedBoxArt != null) {
return HLTBClientImpl.DOMAIN + matchedBoxArt.groupValues[1]
}
}
return ""
}
private fun parseHoursFromRow(row: Int, rows: List<String>): Double {

View File

@ -0,0 +1,32 @@
package be.kuleuven.howlongtobeat.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
@Entity
data class Game(
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "code") val cartCode: String,
@ColumnInfo(name = "hltb") val howLongToBeat: Double,
@ColumnInfo(name = "finished") var finished: Boolean = false,
@PrimaryKey(autoGenerate = true) var id: Int = 0) : java.io.Serializable {
constructor(result: HowLongToBeatResult) : this(result.title, result.cartCode, result.howlong)
val boxartFileName
get() = "box-${cartCode}.jpg"
val snapshotFileName
get() = "snap-${cartCode}.jpg"
fun finish() {
finished = true
}
companion object {
val NONE_YET = Game("No entries yet, add one!", "", 0.0)
val GAME_ID = "GameId"
}
}

View File

@ -0,0 +1,51 @@
package be.kuleuven.howlongtobeat.model
import android.content.Context
import android.graphics.Bitmap
import be.kuleuven.howlongtobeat.ImageRecognizer
import be.kuleuven.howlongtobeat.UnableToFindGameException
import be.kuleuven.howlongtobeat.cartridges.CartridgeFinderViaDuckDuckGo
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepository
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepositoryGekkioFi
import be.kuleuven.howlongtobeat.cartridges.findFirstCartridgeForRepos
import be.kuleuven.howlongtobeat.google.GoogleVisionClient
import be.kuleuven.howlongtobeat.hltb.HLTBClient
import be.kuleuven.howlongtobeat.hltb.HLTBClientImpl
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
/**
* This class separates Android-specific logic with our own domain logic.
* WHY? Because of GameFinderTest and mockability!
*/
class GameFinder(
private val hltbClient: HLTBClient,
private val cartRepos: List<CartridgesRepository>,
private val imageRecognizer: ImageRecognizer) {
companion object {
fun default(context: Context): GameFinder {
// If we fail to find info in the first repo, it falls back to the second one: a (scraped) DuckDuckGo search.
val cartRepos = listOf(
CartridgesRepositoryGekkioFi.fromAsset(context),
CartridgeFinderViaDuckDuckGo(context)
)
return GameFinder(HLTBClientImpl(context), cartRepos, GoogleVisionClient())
}
}
suspend fun findGameBasedOnCameraSnap(picToAnalyze: Bitmap, progress: (msg: String) -> Unit): List<HowLongToBeatResult> {
progress("Recognizing game cart from picture...")
val cartCode = imageRecognizer.recognizeCartCode(picToAnalyze)
?: throw UnableToFindGameException("No cart code in your pic found")
progress("Found cart code $cartCode\nLooking in DBs for matching game...")
val foundCart = findFirstCartridgeForRepos(cartCode, cartRepos)
?: throw UnableToFindGameException("$cartCode is an unknown game cart.")
progress("Valid cart code: $cartCode\n Looking in HLTB for ${foundCart.title}...")
val hltbResults = hltbClient.find(foundCart)
?: throw UnableToFindGameException("HLTB does not know ${foundCart.title}")
return hltbResults
}
}

View File

@ -0,0 +1,25 @@
package be.kuleuven.howlongtobeat.model
import android.content.Context
import be.kuleuven.howlongtobeat.model.room.GameRepositoryRoomImpl
interface GameRepository {
companion object {
/**
* This makes it easier to switch between implementations if needed and does not expose the RoomImpl
* Dependency Injection is the better alternative.
*/
fun defaultImpl(appContext: Context): GameRepository = GameRepositoryRoomImpl(appContext)
}
fun load(): List<Game>
fun update(game: Game)
fun find(id: Int): Game
fun save(game: Game)
fun overwrite(items: List<Game>)
}

View File

@ -1,30 +0,0 @@
package be.kuleuven.howlongtobeat.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
// In practice, you should NOT mix seralizable and entity
// This is just an example to show you both Room and ObjectOutputStream's implementations.
@Serializable
@Entity
data class Todo(
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "is_done") var isDone: Boolean,
@PrimaryKey(autoGenerate = true) var id: Int = 0) : java.io.Serializable {
fun check() {
isDone = true
}
companion object {
fun defaults(): List<Todo> = arrayListOf(
Todo("Get graded", false),
Todo("Pay attention", true),
Todo("Get good at Android dev", false),
Todo("Refactor Java projects", false)
)
}
}

View File

@ -1,8 +0,0 @@
package be.kuleuven.howlongtobeat.model
interface TodoRepository {
fun load(): List<Todo>
fun save(items: List<Todo>)
}

View File

@ -1,34 +0,0 @@
package be.kuleuven.howlongtobeat.model.file
import android.content.Context
import be.kuleuven.howlongtobeat.model.Todo
import be.kuleuven.howlongtobeat.model.TodoRepository
import java.io.EOFException
import java.io.FileNotFoundException
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
class TodoFileRepository(val context: Context) : TodoRepository {
override fun load(): List<Todo> {
try {
val openFileInput = context.openFileInput("todoitems.txt") ?: return Todo.defaults()
ObjectInputStream(openFileInput).use {
return it.readObject() as ArrayList<Todo>
}
} catch(fileNotFound: FileNotFoundException) {
// no file yet, revert to defaults.
} catch(prematureEndOfFile: EOFException) {
// also ignore this: file incomplete/corrupt, revert to defaults.
}
return Todo.defaults()
}
override fun save(items: List<Todo>) {
val openFileOutput = context.openFileOutput("todoitems.txt", Context.MODE_PRIVATE) ?: return
ObjectOutputStream(openFileOutput).use {
it.writeObject(items)
}
}
}

View File

@ -0,0 +1,23 @@
package be.kuleuven.howlongtobeat.model.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import be.kuleuven.howlongtobeat.model.Game
@Dao
interface GameDao {
@Query("SELECT * FROM Game")
fun query(): List<Game>
@Update
fun update(items: List<Game>)
@Query("DELETE FROM Game")
fun deleteAll()
@Insert
fun insert(items: List<Game>)
}

View File

@ -0,0 +1,10 @@
package be.kuleuven.howlongtobeat.model.room
import androidx.room.Database
import androidx.room.RoomDatabase
import be.kuleuven.howlongtobeat.model.Game
@Database(entities = arrayOf(Game::class), version = 1)
abstract class GameDatabase : RoomDatabase() {
abstract fun todoDao() : GameDao
}

View File

@ -0,0 +1,40 @@
package be.kuleuven.howlongtobeat.model.room
import android.content.Context
import androidx.room.Room
import be.kuleuven.howlongtobeat.model.Game
import be.kuleuven.howlongtobeat.model.GameRepository
class GameRepositoryRoomImpl(appContext: Context) :
GameRepository {
private val db: GameDatabase
private val dao: GameDao
init {
db = Room.databaseBuilder(appContext, GameDatabase::class.java, "todo-db")
.allowMainThreadQueries()
.build()
dao = db.todoDao()
}
override fun load(): List<Game> = dao.query()
override fun update(game: Game) = dao.update(listOf(game))
override fun find(id: Int): Game = load().single { it.id == id }
override fun save(game: Game) {
db.runInTransaction {
dao.insert(listOf(game))
}
}
override fun overwrite(items: List<Game>) {
// You'll learn more about transactions in the database course in the 3rd academic year.
db.runInTransaction {
dao.deleteAll()
dao.insert(items)
}
}
}

View File

@ -1,23 +0,0 @@
package be.kuleuven.howlongtobeat.model.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import be.kuleuven.howlongtobeat.model.Todo
@Dao
interface TodoDao {
@Query("SELECT * FROM Todo")
fun query(): List<Todo>
@Update
fun update(items: List<Todo>)
@Query("DELETE FROM Todo")
fun deleteAll()
@Insert
fun insert(items: List<Todo>)
}

View File

@ -1,10 +0,0 @@
package be.kuleuven.howlongtobeat.model.room
import androidx.room.Database
import androidx.room.RoomDatabase
import be.kuleuven.howlongtobeat.model.Todo
@Database(entities = arrayOf(Todo::class), version = 1)
abstract class TodoDatabase : RoomDatabase() {
abstract fun todoDao() : TodoDao
}

View File

@ -1,30 +0,0 @@
package be.kuleuven.howlongtobeat.model.room
import android.content.Context
import androidx.room.Room
import be.kuleuven.howlongtobeat.model.Todo
import be.kuleuven.howlongtobeat.model.TodoRepository
class TodoRoomRepository(appContext: Context) : TodoRepository {
private val db: TodoDatabase
private val dao: TodoDao
init {
db = Room.databaseBuilder(appContext, TodoDatabase::class.java, "todo-db")
.allowMainThreadQueries()
.build()
dao = db.todoDao()
}
override fun load(): List<Todo> = dao.query()
override fun save(items: List<Todo>) {
// You'll learn more about transactions in the database course in the 3rd academic year.
db.runInTransaction {
dao.deleteAll()
dao.insert(items)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

View File

@ -7,10 +7,17 @@
android:id="@+id/drawerLayout"
tools:context=".MainActivity">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/frmTodoContainer" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:navGraph="@navigation/nav_graph"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.navigation.NavigationView
android:id="@+id/navView"

View File

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imgDetailBoxArt"
android:layout_width="279dp"
android:layout_height="224dp"
android:layout_marginTop="36dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/txtDetailTitle"
app:srcCompat="@drawable/emptygb" />
<ImageView
android:id="@+id/imgDetailSnapshot"
android:layout_width="213dp"
android:layout_height="194dp"
app:layout_constraintBottom_toTopOf="@+id/txtDetailGameStats"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imgDetailBoxArt"
app:layout_constraintVertical_bias="0.413"
app:srcCompat="@drawable/emptygb" />
<TextView
android:id="@+id/txtDetailTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Game Title"
android:textAlignment="center"
android:textSize="28sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/txtDetailGameStats"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="28dp"
android:text="stats"
android:textSize="24sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/txtDetailFinishedLabel"
app:layout_constraintHorizontal_bias="0.057"
app:layout_constraintStart_toStartOf="parent" />
<CheckBox
android:id="@+id/chkDetailFinished"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/txtDetailFinishedLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:text="Finished:"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/chkDetailFinished"
app:layout_constraintHorizontal_bias="0.879"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,33 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvwTodo"
android:id="@+id/rvGameList"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/edtTodo"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/edtTodo"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="new TODO"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btnAddTodo"
app:layout_constraintStart_toStartOf="parent" />
<Button
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btnAddTodo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Add"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:src="@android:drawable/ic_menu_add"
app:layout_constraintBottom_toBottomOf="parent"
android:contentDescription="Add New Game"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvHltbResult"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -12,16 +12,31 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toTopOf="parent"
android:indeterminate="true" />
<TextView
android:id="@+id/txtLoading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Please wait, fetching..."
android:textSize="16sp"
app:layout_constraintBottom_toTopOf="@+id/indeterminateBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btnRetryAfterLoading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:contentDescription="Retry"
android:src="@android:drawable/ic_menu_rotate"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -13,12 +13,12 @@
android:text="Some Title"
android:textSize="24sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/chkTodoDone"
app:layout_constraintEnd_toStartOf="@+id/chkGameFinished"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<CheckBox
android:id="@+id/chkTodoDone"
android:id="@+id/chkGameFinished"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="100dp"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:longClickable="true"
android:padding="16dp">
<TextView
android:id="@+id/txtHltbItemResult"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Some Result"
android:textSize="24sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/imgHltbItemResult"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/imgHltbItemResult"
android:layout_width="105dp"
android:layout_height="72dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.727"
app:srcCompat="@android:drawable/ic_menu_gallery" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,7 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="150dp"
android:background="@color/design_default_color_primary">
<ImageView
android:id="@+id/imageView"
android:layout_width="179dp"
android:layout_height="152dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="4dp"
android:translationX="10dp"
android:translationY="-20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/sml2" />
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:text="HLTB App"
android:textColor="@color/white"
android:textSize="24sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -2,12 +2,9 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/mnuClear"
android:title="Clear All Items" />
android:title="Remove All Games" />
<item
android:id="@+id/mnuClearLatest"
android:title="Clear Latest Item" />
<item
android:id="@+id/mnuReset"
android:title="Reset to Default Items" />
android:id="@+id/mnuStats"
android:title="Statistics" />
</menu>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_graph"
app:startDestination="@id/gameListFragment">
<fragment
android:id="@+id/gameListFragment"
android:name="be.kuleuven.howlongtobeat.GameListFragment"
android:label="GameListFragment" >
<action
android:id="@+id/action_gameListFragment_to_loadingFragment"
app:destination="@id/loadingFragment" />
<action
android:id="@+id/action_gameListFragment_to_gameDetailFragment"
app:destination="@id/gameDetailFragment" />
</fragment>
<fragment
android:id="@+id/loadingFragment"
android:name="be.kuleuven.howlongtobeat.LoadingFragment"
android:label="LoadingFragment" >
<action
android:id="@+id/action_loadingFragment_to_hltbResultsFragment"
app:destination="@id/hltbResultsFragment" />
</fragment>
<fragment
android:id="@+id/hltbResultsFragment"
android:name="be.kuleuven.howlongtobeat.HltbResultsFragment"
android:label="HltbResultsFragment" >
<action
android:id="@+id/action_hltbResultsFragment_to_gameListFragment"
app:destination="@id/gameListFragment" />
</fragment>
<fragment
android:id="@+id/gameDetailFragment"
android:name="be.kuleuven.howlongtobeat.GameDetailFragment"
android:label="GameDetailFragment" />
</navigation>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="cached_files"
path="." />
<files-path
name="images"
path="." />
</paths>

View File

@ -1,10 +1,28 @@
package be.kuleuven.howlongtobeat.cartridges
import junit.framework.Assert.assertEquals
import junit.framework.TestCase.*
import org.junit.Test
class CartridgeTest {
@Test
fun isValid_OnlyCartridgeCodeItself_IsValid() {
assertTrue(Cartridge.isValid("DMG-MQ-EUR"))
assertTrue(Cartridge.isValid("DMG-MQ-USA-1"))
assertTrue(Cartridge.isValid("CGB-ABC-DEF-WHATEVER"))
}
@Test
fun isValid_Empty_IsNotValid() {
assertFalse(Cartridge.isValid(""))
}
@Test
fun isValid_CatridgeCodeWithOtherJunkOnNewLine_IsNotValid() {
assertFalse(Cartridge.isValid("DMG-MQ-EUR\nMADE IN JAPAN"))
}
@Test
fun titleReplacesIrrelevantDetailsFromName() {
val cart = Cartridge("type", "name (irrelevant details please remove thxxx)", "DMG-whatever")

View File

@ -0,0 +1,64 @@
package be.kuleuven.howlongtobeat.car
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepositoryGekkioFi
import junit.framework.Assert.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.util.concurrent.Executors
class CartridgesRepositoryGekkioFiTest {
private lateinit var repo: CartridgesRepositoryGekkioFi
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
@Before
fun setUp() {
repo = javaClass.getResource("/cartridges.csv").openStream().use {
CartridgesRepositoryGekkioFi(it)
}
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
dispatcher.close()
}
@Test
fun noSingleCodeShouldBeEmpty() {
repo.cartridges.forEach {
assertTrue("Code of ${it.title} should be filled in but is '${it.code}'", it.code.length > 4)
}
}
@Test
fun findReturnsCartridgeObjectOfCode() = runBlocking {
launch(Dispatchers.Main) {
val smbDeluxe = repo.find("CGB-AHYE-USA")
assertTrue(smbDeluxe!!.name.contains("Super Mario Bros. Deluxe"))
}
println("done")
}
@Test
fun readsWholeCsvFileAsListOfCartridges() {
assertFalse(repo.cartridges.isEmpty())
val smbDeluxe = repo.cartridges.find {
it.code == "CGB-AHYE-USA"
}
assertTrue(smbDeluxe != null)
assertTrue(smbDeluxe!!.name.contains("Super Mario Bros. Deluxe"))
assertEquals("Super Mario Bros. Deluxe", smbDeluxe.title)
}
}

View File

@ -1,39 +0,0 @@
package be.kuleuven.howlongtobeat.car
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepository
import junit.framework.Assert.*
import org.junit.Before
import org.junit.Test
class CartridgesRepositoryTest {
private lateinit var repo: CartridgesRepository
@Before
fun setUp() {
repo = javaClass.getResource("/cartridges.csv").openStream().use {
CartridgesRepository(it)
}
}
@Test
fun noSingleCodeShouldBeEmpty() {
repo.cartridges.forEach {
assertTrue("Code of ${it.title} should be filled in but is '${it.code}'", it.code.length > 4)
}
}
@Test
fun readsWholeCsvFileAsListOfCartridges() {
assertFalse(repo.cartridges.isEmpty())
val smbDeluxe = repo.cartridges.find {
it.code == "CGB-AHYE-USA"
}
assertTrue(smbDeluxe != null)
assertTrue(smbDeluxe!!.name.contains("Super Mario Bros. Deluxe"))
assertEquals("Super Mario Bros. Deluxe", smbDeluxe.title)
}
}

View File

@ -0,0 +1,994 @@
package be.kuleuven.howlongtobeat.cartridges
import junit.framework.TestCase.assertEquals
import org.junit.Test
import junit.framework.TestCase.assertNull as assertNull1
class DuckDuckGoResultParserTest {
@Test
fun parse_marioGolfCodeSample() {
val html = """
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--[if IE 6]><html class="ie6" xmlns="http://www.w3.org/1999/xhtml"><![endif]-->
<!--[if IE 7]><html class="lt-ie8 lt-ie9" xmlns="http://www.w3.org/1999/xhtml"><![endif]-->
<!--[if IE 8]><html class="lt-ie9" xmlns="http://www.w3.org/1999/xhtml"><![endif]-->
<!--[if gt IE 8]><!--><html xmlns="http://www.w3.org/1999/xhtml"><!--<![endif]-->
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0, user-scalable=1" />
<meta name="referrer" content="origin" />
<meta name="HandheldFriendly" content="true" />
<meta name="robots" content="noindex, nofollow" />
<title>cgb-awxp-eur-1 at DuckDuckGo</title>
<link title="DuckDuckGo (HTML)" type="application/opensearchdescription+xml" rel="search" href="//duckduckgo.com/opensearch_html_v2.xml" />
<link href="//duckduckgo.com/favicon.ico" rel="shortcut icon" />
<link rel="icon" href="//duckduckgo.com/favicon.ico" type="image/x-icon" />
<link rel="apple-touch-icon" href="//duckduckgo.com/assets/logo_icon128.v101.png"/>
<link rel="image_src" href="//duckduckgo.com/assets/logo_homepage.normal.v101.png"/>
<link type="text/css" media="handheld, all" href="//duckduckgo.com/h1997.css" rel="stylesheet" />
</head>
<body class="body--html">
<a name="top" id="top"></a>
<form action="/html/" method="post">
<input type="text" name="state_hidden" id="state_hidden" />
</form>
<div>
<div class="site-wrapper-border"></div>
<div id="header" class="header cw header--html">
<a title="DuckDuckGo" href="/html/" class="header__logo-wrap"></a>
<form name="x" class="header__form" action="/html/" method="post">
<div class="search search--header">
<input name="q" autocomplete="off" class="search__input" id="search_form_input_homepage" type="text" value="cgb-awxp-eur-1" />
<input name="b" id="search_button_homepage" class="search__button search__button--html" value="" title="Search" alt="Search" type="submit" />
</div>
<div class="frm__select">
<select name="kl">
<option value="" >All Regions</option>
<option value="ar-es" >Argentina</option>
<option value="au-en" >Australia</option>
<option value="at-de" >Austria</option>
<option value="be-fr" >Belgium (fr)</option>
<option value="be-nl" >Belgium (nl)</option>
<option value="br-pt" >Brazil</option>
<option value="bg-bg" >Bulgaria</option>
<option value="ca-en" >Canada (en)</option>
<option value="ca-fr" >Canada (fr)</option>
<option value="ct-ca" >Catalonia</option>
<option value="cl-es" >Chile</option>
<option value="cn-zh" >China</option>
<option value="co-es" >Colombia</option>
<option value="hr-hr" >Croatia</option>
<option value="cz-cs" >Czech Republic</option>
<option value="dk-da" >Denmark</option>
<option value="ee-et" >Estonia</option>
<option value="fi-fi" >Finland</option>
<option value="fr-fr" >France</option>
<option value="de-de" >Germany</option>
<option value="gr-el" >Greece</option>
<option value="hk-tzh" >Hong Kong</option>
<option value="hu-hu" >Hungary</option>
<option value="in-en" >India (en)</option>
<option value="id-en" >Indonesia (en)</option>
<option value="ie-en" >Ireland</option>
<option value="il-en" >Israel (en)</option>
<option value="it-it" >Italy</option>
<option value="jp-jp" >Japan</option>
<option value="kr-kr" >Korea</option>
<option value="lv-lv" >Latvia</option>
<option value="lt-lt" >Lithuania</option>
<option value="my-en" >Malaysia (en)</option>
<option value="mx-es" >Mexico</option>
<option value="nl-nl" >Netherlands</option>
<option value="nz-en" >New Zealand</option>
<option value="no-no" >Norway</option>
<option value="pk-en" >Pakistan (en)</option>
<option value="pe-es" >Peru</option>
<option value="ph-en" >Philippines (en)</option>
<option value="pl-pl" >Poland</option>
<option value="pt-pt" >Portugal</option>
<option value="ro-ro" >Romania</option>
<option value="ru-ru" >Russia</option>
<option value="xa-ar" >Saudi Arabia</option>
<option value="sg-en" >Singapore</option>
<option value="sk-sk" >Slovakia</option>
<option value="sl-sl" >Slovenia</option>
<option value="za-en" >South Africa</option>
<option value="es-ca" >Spain (ca)</option>
<option value="es-es" >Spain (es)</option>
<option value="se-sv" >Sweden</option>
<option value="ch-de" >Switzerland (de)</option>
<option value="ch-fr" >Switzerland (fr)</option>
<option value="tw-tzh" >Taiwan</option>
<option value="th-en" >Thailand (en)</option>
<option value="tr-tr" >Turkey</option>
<option value="us-en" >US (English)</option>
<option value="us-es" >US (Spanish)</option>
<option value="ua-uk" >Ukraine</option>
<option value="uk-en" >United Kingdom</option>
<option value="vn-en" >Vietnam (en)</option>
</select>
</div>
<div class="frm__select frm__select--last">
<select class="" name="df">
<option value="" selected>Any Time</option>
<option value="d" >Past Day</option>
<option value="w" >Past Week</option>
<option value="m" >Past Month</option>
<option value="y" >Past Year</option>
</select>
</div>
</form>
</div>
<!-- Web results are present -->
<div>
<div class="serp__results">
<div id="links" class="results">
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fvgdb.uk%2Fmario%2Dgolf%2Fgame%2F48484&amp;rut=b6b8b1ab81b51d84905332762ecdb6db58a60d37b266e4b6fdb2cfd7005feea0">Get information and compare prices of Mario Golf for Game Boy | VGDb</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fvgdb.uk%2Fmario%2Dgolf%2Fgame%2F48484&amp;rut=b6b8b1ab81b51d84905332762ecdb6db58a60d37b266e4b6fdb2cfd7005feea0">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/vgdb.uk.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fvgdb.uk%2Fmario%2Dgolf%2Fgame%2F48484&amp;rut=b6b8b1ab81b51d84905332762ecdb6db58a60d37b266e4b6fdb2cfd7005feea0">
vgdb.uk/mario-golf/game/48484
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fvgdb.uk%2Fmario%2Dgolf%2Fgame%2F48484&amp;rut=b6b8b1ab81b51d84905332762ecdb6db58a60d37b266e4b6fdb2cfd7005feea0">045496730963. MPN. <b>CGB-AWXP-EUR-1</b>. Release Date.</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.ebay.de%2Fitm%2FMario%2DGolf%2DNintendo%2DGame%2DBoy%2Dcolor%2DAdvance%2DGBC%2DGBA%2DCGB%2DAWXP%2DEUR%2F264853273128%3Fepid%3D167015543%26hash%3Ditem3daa7c3a28%3Ag%3A0U0AAOSwC%2D5fUT36&amp;rut=5d364b7b299d63d00736bfd7fd0835ac27551cd84b2424019d36a682ad982aea">Mario Golf - Nintendo Game Boy color / Advance GBC GBA... | eBay</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.ebay.de%2Fitm%2FMario%2DGolf%2DNintendo%2DGame%2DBoy%2Dcolor%2DAdvance%2DGBC%2DGBA%2DCGB%2DAWXP%2DEUR%2F264853273128%3Fepid%3D167015543%26hash%3Ditem3daa7c3a28%3Ag%3A0U0AAOSwC%2D5fUT36&amp;rut=5d364b7b299d63d00736bfd7fd0835ac27551cd84b2424019d36a682ad982aea">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/www.ebay.de.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.ebay.de%2Fitm%2FMario%2DGolf%2DNintendo%2DGame%2DBoy%2Dcolor%2DAdvance%2DGBC%2DGBA%2DCGB%2DAWXP%2DEUR%2F264853273128%3Fepid%3D167015543%26hash%3Ditem3daa7c3a28%3Ag%3A0U0AAOSwC%2D5fUT36&amp;rut=5d364b7b299d63d00736bfd7fd0835ac27551cd84b2424019d36a682ad982aea">
www.ebay.de/itm/Mario-Golf-Nintendo-Game-Boy-color-Advance-GBC-GBA-CGB-AWXP-EUR/264853273128?epid=167015543&amp;hash=item3daa7c3a28:g:0U0AAOSwC-5fUT36
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.ebay.de%2Fitm%2FMario%2DGolf%2DNintendo%2DGame%2DBoy%2Dcolor%2DAdvance%2DGBC%2DGBA%2DCGB%2DAWXP%2DEUR%2F264853273128%3Fepid%3D167015543%26hash%3Ditem3daa7c3a28%3Ag%3A0U0AAOSwC%2D5fUT36&amp;rut=5d364b7b299d63d00736bfd7fd0835ac27551cd84b2424019d36a682ad982aea">Super mario bros deluxe game boy color game nintendo original gbc <b>EUR-1</b> version. Golf. Herstellernummer: <b>CGB-AWXP-EUR</b>. Herausgeber: Nintendo.</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=http%3A%2F%2Fpassat.neostrada.pl%2Fzabikt%2FGB.txt&amp;rut=df8a2bed4111ed3dc8458c505782b07b995d248bdb2adf11a264f0c8e216b6ee">passat.neostrada.pl/zabikt/GB.txt</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=http%3A%2F%2Fpassat.neostrada.pl%2Fzabikt%2FGB.txt&amp;rut=df8a2bed4111ed3dc8458c505782b07b995d248bdb2adf11a264f0c8e216b6ee">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/passat.neostrada.pl.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=http%3A%2F%2Fpassat.neostrada.pl%2Fzabikt%2FGB.txt&amp;rut=df8a2bed4111ed3dc8458c505782b07b995d248bdb2adf11a264f0c8e216b6ee">
passat.neostrada.pl/zabikt/GB.txt
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=http%3A%2F%2Fpassat.neostrada.pl%2Fzabikt%2FGB.txt&amp;rut=df8a2bed4111ed3dc8458c505782b07b995d248bdb2adf11a264f0c8e216b6ee">and Jerry <b>CGB</b>-AW8A-<b>EUR</b> DMG-A03-10 <b>CGB</b>-AW8A- 11&#x27;00 Nintendo Wario Land 3 <b>CGB-AWXP-EUR-1</b> DMG-A08-01 <b>CGB</b>-<b>AWXP</b>- 07&#x27;99 <b>CGB</b>-BBZD- 05&#x27;02 Banpresto DragonBall Z - Legendare Superkampfer <b>CGB</b>-BCKP-<b>EUR</b> DMG-A07-01 <b>CGB</b>-BCKP- 10&#x27;00 THQ Chicken Run...</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.xe.com%2Fcurrencyconverter%2Fconvert%2F%3FAmount%3D1%26From%3DEUR%26To%3DXPF&amp;rut=786e13090f4b7968730f2b4700edf1855318e23a3e904b10640c077e450018b5">Convert 1 Euro to CFP Franc - EUR to XPF Exchange Rates | Xe</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.xe.com%2Fcurrencyconverter%2Fconvert%2F%3FAmount%3D1%26From%3DEUR%26To%3DXPF&amp;rut=786e13090f4b7968730f2b4700edf1855318e23a3e904b10640c077e450018b5">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/www.xe.com.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.xe.com%2Fcurrencyconverter%2Fconvert%2F%3FAmount%3D1%26From%3DEUR%26To%3DXPF&amp;rut=786e13090f4b7968730f2b4700edf1855318e23a3e904b10640c077e450018b5">
www.xe.com/currencyconverter/convert/?Amount=1&amp;From=EUR&amp;To=XPF
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.xe.com%2Fcurrencyconverter%2Fconvert%2F%3FAmount%3D1%26From%3DEUR%26To%3DXPF&amp;rut=786e13090f4b7968730f2b4700edf1855318e23a3e904b10640c077e450018b5">Get live mid-market exchange rates, historical rates and data &amp; currency charts for <b>EUR</b> to XPF with Xe&#x27;s free Currency Converter.</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2F1prime.ru%2FFinancial_market%2F20210818%2F834477683.html&amp;rut=98ca5523ff9959af22ddde1d49a41c29cec74571bf11f14960fb6e2753d47349">Евро сдувается: когда пора бежать в обменник - ПРАЙМ, 18.08.2021</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2F1prime.ru%2FFinancial_market%2F20210818%2F834477683.html&amp;rut=98ca5523ff9959af22ddde1d49a41c29cec74571bf11f14960fb6e2753d47349">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/1prime.ru.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2F1prime.ru%2FFinancial_market%2F20210818%2F834477683.html&amp;rut=98ca5523ff9959af22ddde1d49a41c29cec74571bf11f14960fb6e2753d47349">
1prime.ru/Financial_market/20210818/834477683.html
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2F1prime.ru%2FFinancial_market%2F20210818%2F834477683.html&amp;rut=98ca5523ff9959af22ddde1d49a41c29cec74571bf11f14960fb6e2753d47349"><b>EUR</b>/RUB. <b>EUR</b>/CHF.</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.cgb.fr%2Fespagne%2Dserie%2Deuro%2Dbrillant%2Duniversel%2D2002%2Dbu%2CEA_102_101_117_95_54_57_48_52_52_51%2Ca.html&amp;rut=4e2756edf796854d544428c15a6044ce2bba3dc48072b3b8ea2b6c465b3cd73d">ESPAGNE SÉRIE Euro BRILLANT UNIVERSEL 2002 Madrid...</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.cgb.fr%2Fespagne%2Dserie%2Deuro%2Dbrillant%2Duniversel%2D2002%2Dbu%2CEA_102_101_117_95_54_57_48_52_52_51%2Ca.html&amp;rut=4e2756edf796854d544428c15a6044ce2bba3dc48072b3b8ea2b6c465b3cd73d">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/www.cgb.fr.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.cgb.fr%2Fespagne%2Dserie%2Deuro%2Dbrillant%2Duniversel%2D2002%2Dbu%2CEA_102_101_117_95_54_57_48_52_52_51%2Ca.html&amp;rut=4e2756edf796854d544428c15a6044ce2bba3dc48072b3b8ea2b6c465b3cd73d">
www.cgb.fr/espagne-serie-euro-brillant-universel-2002-bu,EA_102_101_117_95_54_57_48_52_52_51,a.html
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.cgb.fr%2Fespagne%2Dserie%2Deuro%2Dbrillant%2Duniversel%2D2002%2Dbu%2CEA_102_101_117_95_54_57_48_52_52_51%2Ca.html&amp;rut=4e2756edf796854d544428c15a6044ce2bba3dc48072b3b8ea2b6c465b3cd73d"><b>cgb</b>.fr. La vente sera clôturée à l&#x27;heure indiquée sur la fiche descriptive, toute offre reçue après l&#x27;heure de clôture ne sera pas validée. <b>cgb</b>.fr utilise des cookies pour vous garantir une meilleure expérience utilisateur et réaliser des statistiques de visites.</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.chamber%2Dinternational.com%2Fexporting%2Dchamber%2Dinternational%2Fdocumentation%2Dfor%2Dexport%2Dand%2Dimport%2Feur%2D1%2Dcertificates%2F&amp;rut=faece7b1a1828d2ac5bee57a7c3582afbbd2defc0f12b9cfdabcce29c7728229">EUR1 certificates</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.chamber%2Dinternational.com%2Fexporting%2Dchamber%2Dinternational%2Fdocumentation%2Dfor%2Dexport%2Dand%2Dimport%2Feur%2D1%2Dcertificates%2F&amp;rut=faece7b1a1828d2ac5bee57a7c3582afbbd2defc0f12b9cfdabcce29c7728229">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/www.chamber-international.com.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.chamber%2Dinternational.com%2Fexporting%2Dchamber%2Dinternational%2Fdocumentation%2Dfor%2Dexport%2Dand%2Dimport%2Feur%2D1%2Dcertificates%2F&amp;rut=faece7b1a1828d2ac5bee57a7c3582afbbd2defc0f12b9cfdabcce29c7728229">
www.chamber-international.com/exporting-chamber-international/documentation-for-export-and-import/eur-1-certificates/
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.chamber%2Dinternational.com%2Fexporting%2Dchamber%2Dinternational%2Fdocumentation%2Dfor%2Dexport%2Dand%2Dimport%2Feur%2D1%2Dcertificates%2F&amp;rut=faece7b1a1828d2ac5bee57a7c3582afbbd2defc0f12b9cfdabcce29c7728229"><b>EUR</b> <b>1</b> certificates are issued by Chamber International under authority from HM Customs and Excise. No matter where you are in the UK we have a dedicated service that can walk you through the <b>EUR1</b> process, to take away the confusion so you can get on with what matters to you.</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEUR.1_movement_certificate&amp;rut=24216430601fd3be32ae5f33be1579dd66e4e2a2b52d4e8edf1d1df49da81a76">EUR.1 movement certificate - Wikipedia</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEUR.1_movement_certificate&amp;rut=24216430601fd3be32ae5f33be1579dd66e4e2a2b52d4e8edf1d1df49da81a76">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/en.wikipedia.org.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEUR.1_movement_certificate&amp;rut=24216430601fd3be32ae5f33be1579dd66e4e2a2b52d4e8edf1d1df49da81a76">
en.wikipedia.org/wiki/EUR.1_movement_certificate
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEUR.1_movement_certificate&amp;rut=24216430601fd3be32ae5f33be1579dd66e4e2a2b52d4e8edf1d1df49da81a76">The <b>EUR.1</b> movement certificate (also known as <b>EUR.1</b> certificate, or <b>EUR.1</b>) is a form used in international commodity traffic. The <b>EUR.1</b> is most importantly recognized as a certificate of origin in...</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.tinko.ru%2Fcatalog%2Fproduct%2F245755%2F&amp;rut=6690f23522657070be76b9ad00b6ba1084ebed21008130b7a37e8c3fa25874ac">CGB-1U-19 (7113c): Медная шина заземления, 19&quot;</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.tinko.ru%2Fcatalog%2Fproduct%2F245755%2F&amp;rut=6690f23522657070be76b9ad00b6ba1084ebed21008130b7a37e8c3fa25874ac">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/www.tinko.ru.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.tinko.ru%2Fcatalog%2Fproduct%2F245755%2F&amp;rut=6690f23522657070be76b9ad00b6ba1084ebed21008130b7a37e8c3fa25874ac">
www.tinko.ru/catalog/product/245755/
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.tinko.ru%2Fcatalog%2Fproduct%2F245755%2F&amp;rut=6690f23522657070be76b9ad00b6ba1084ebed21008130b7a37e8c3fa25874ac"><b>CGB</b>-1U-19 (7113c). Избранное. Сравнить.</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.justetf.com%2Fde%2Fetf%2Dprofile.html%3Fisin%3DIE00B3RBWM25&amp;rut=6ef75eb1798a163400e483634db05a9bb8eb3b05d1b4b99c19cd2b62b91313c8">Vanguard FTSE All-World UCITS ETF Distributing | A1JX52</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.justetf.com%2Fde%2Fetf%2Dprofile.html%3Fisin%3DIE00B3RBWM25&amp;rut=6ef75eb1798a163400e483634db05a9bb8eb3b05d1b4b99c19cd2b62b91313c8">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/www.justetf.com.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.justetf.com%2Fde%2Fetf%2Dprofile.html%3Fisin%3DIE00B3RBWM25&amp;rut=6ef75eb1798a163400e483634db05a9bb8eb3b05d1b4b99c19cd2b62b91313c8">
www.justetf.com/de/etf-profile.html?isin=IE00B3RBWM25
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.justetf.com%2Fde%2Fetf%2Dprofile.html%3Fisin%3DIE00B3RBWM25&amp;rut=6ef75eb1798a163400e483634db05a9bb8eb3b05d1b4b99c19cd2b62b91313c8">Ausschüttungen der letzten 12 Monate. <b>EUR</b> 1,42. <b>EUR</b> 1,42. Premium-Funktion. 2020.</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.shiphub.co%2Feur%2D1%2Ddocument%2F&amp;rut=a7ea72c573daebf0f67f613309117c6ec11844bff0cd1b2afbb2aa37948f3186">EUR 1 document - Information about EUR 1 | ShipHub</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.shiphub.co%2Feur%2D1%2Ddocument%2F&amp;rut=a7ea72c573daebf0f67f613309117c6ec11844bff0cd1b2afbb2aa37948f3186">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/www.shiphub.co.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.shiphub.co%2Feur%2D1%2Ddocument%2F&amp;rut=a7ea72c573daebf0f67f613309117c6ec11844bff0cd1b2afbb2aa37948f3186">
www.shiphub.co/eur-1-document/
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.shiphub.co%2Feur%2D1%2Ddocument%2F&amp;rut=a7ea72c573daebf0f67f613309117c6ec11844bff0cd1b2afbb2aa37948f3186">The <b>EUR</b> <b>1</b> document also helps to set the rates of customs duties. This is related to the fact that goods from one region of the world in different countries The <b>EUR</b> <b>1</b> document is one of the most common certificates of origin on the international market. Well, it is issued when there is a preferential bilateral...</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Feur1.ch%2Feur1&amp;rut=9d288ecd9c501724cd409741f2f94aa7ae11ea635feb3abba833b9ebc779b1fa">EUR1 - eur1.ch Informationen zu EUR1 bestellen</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Feur1.ch%2Feur1&amp;rut=9d288ecd9c501724cd409741f2f94aa7ae11ea635feb3abba833b9ebc779b1fa">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/eur1.ch.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Feur1.ch%2Feur1&amp;rut=9d288ecd9c501724cd409741f2f94aa7ae11ea635feb3abba833b9ebc779b1fa">
eur1.ch/eur1
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Feur1.ch%2Feur1&amp;rut=9d288ecd9c501724cd409741f2f94aa7ae11ea635feb3abba833b9ebc779b1fa"><b>EUR1</b> Formular online ausfüllen, <b>EUR1</b> Download, <b>EUR1</b> Fahrzeug, <b>EUR1</b> PKW, <b>EUR1</b> Export, Präferenz, <b>EUR1</b> Formular online, Ursprungserklärung Ein <b>EUR.1</b> ist ein Formular, welches im internationalen Handelswarenverkehr angewendet wird. Die Anwendung basiert auf diversen bi- und...</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fec.europa.eu%2Finfo%2Fstrategy%2Frecovery%2Dplan%2Deurope_en&amp;rut=4e8c965208bfb5af73f9a91301e671a1f719f15a7cb4e4f00761367ceadfcbe2">Recovery plan for Europe | European Commission</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fec.europa.eu%2Finfo%2Fstrategy%2Frecovery%2Dplan%2Deurope_en&amp;rut=4e8c965208bfb5af73f9a91301e671a1f719f15a7cb4e4f00761367ceadfcbe2">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/ec.europa.eu.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fec.europa.eu%2Finfo%2Fstrategy%2Frecovery%2Dplan%2Deurope_en&amp;rut=4e8c965208bfb5af73f9a91301e671a1f719f15a7cb4e4f00761367ceadfcbe2">
ec.europa.eu/info/strategy/recovery-plan-europe_en
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fec.europa.eu%2Finfo%2Fstrategy%2Frecovery%2Dplan%2Deurope_en&amp;rut=4e8c965208bfb5af73f9a91301e671a1f719f15a7cb4e4f00761367ceadfcbe2"><b>EUR</b>, der skal bidrage til opbygningen af et grønnere, mere digitalt og mere modstandsdygtigt Europa). (Bugetul UE: Comisia Europeană salută acordul privind pachetul în valoare de 1,8 mii de miliarde <b>EUR</b> care va contribui la construirea unei Europe mai verzi, mai digitale și mai reziliente).</a>
<div class="clear"></div>
</div>
</div>
<div class="result results_links results_links_deep web-result ">
<div class="links_main links_deep result__body"> <!-- This is the visible part -->
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D685J1hos%2Dzw&amp;rut=0de6e73357766837f1535544102aba3a21dfeb0ebe95183417e313e54b315bf1">EUR.1 - einfach erklärt (2020) - YouTube</a>
</h2>
<div class="result__extras">
<div class="result__extras__url">
<span class="result__icon">
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D685J1hos%2Dzw&amp;rut=0de6e73357766837f1535544102aba3a21dfeb0ebe95183417e313e54b315bf1">
<img class="result__icon__img" width="16" height="16" alt=""
src="//external-content.duckduckgo.com/ip3/www.youtube.com.ico" name="i15" />
</a>
</span>
<a class="result__url" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D685J1hos%2Dzw&amp;rut=0de6e73357766837f1535544102aba3a21dfeb0ebe95183417e313e54b315bf1">
www.youtube.com/watch?v=685J1hos-zw
</a>
</div>
</div>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D685J1hos%2Dzw&amp;rut=0de6e73357766837f1535544102aba3a21dfeb0ebe95183417e313e54b315bf1">Die <b>EUR.1</b> ermöglicht Importeuren, Waren zu sogenannten Präferenzzollsätzen einzuführen. Sie gilt für fast alle Länder mit denen die EU Freihandelsabkommen abgeschlossen hat. Notwendig für eine rechtmäßigen Ausstellung ist...</a>
<div class="clear"></div>
</div>
</div>
<div class="nav-link">
<form action="/html/" method="post">
<input type="submit" class='btn btn--alt' value="Next" />
<input type="hidden" name="q" value="cgb-awxp-eur-1" />
<input type="hidden" name="s" value="30" />
<input type="hidden" name="nextParams" value="" />
<input type="hidden" name="v" value="l" />
<input type="hidden" name="o" value="json" />
<input type="hidden" name="dc" value="15" />
<input type="hidden" name="api" value="d.js" />
<input type="hidden" name="vqd" value="3-170112077246644806044058132965997084141-24182048638364464238089820915205130627" />
<input name="kl" value="wt-wt" type="hidden" />
</form>
</div>
<div class=" feedback-btn">
<a rel="nofollow" href="//duckduckgo.com/feedback.html" target="_new">Feedback</a>
</div>
<div class="clear"></div>
</div>
</div> <!-- links wrapper //-->
</div>
</div>
<div id="bottom_spacing2"></div>
<img src="//duckduckgo.com/t/sl_h"/>
</body>
</html>
""".trimIndent()
val cart = DuckDuckGoResultParser.parse(html, "cgb-awxp-eur-1")
assertEquals("mario golf", cart?.title)
}
@Test
fun parse_NoResultsFound_returnsNull() {
val result = DuckDuckGoResultParser.parse("whooptie-doo", "some-code")
assertNull1(result)
}
@Test
fun parse_ResultWithStuffBetweenBrackets_removesThose() {
val html = """
<a rel="nofollow" class="result__a" href="url">My Little Pony (USA Release)!</a>
""".trimIndent()
val cart = DuckDuckGoResultParser.parse(html, "some-code")
assertEquals("my little pony", cart?.title)
}
@Test
fun parse_ResultWithCodeAgainInResult_removesRedundantCode() {
val html = """
<a rel="nofollow" class="result__a" href="url">My Little Pony (USA Release) DMG-PONY-007 ebay</a>
""".trimIndent()
val cart = DuckDuckGoResultParser.parse(html, "DMG-PONY-007")
assertEquals("my little pony", cart?.title)
assertEquals("DMG-PONY-007", cart?.code)
}
}

View File

@ -1,14 +1,16 @@
package be.kuleuven.howlongtobeat.hltb
import be.kuleuven.howlongtobeat.cartridges.Cartridge
import junit.framework.TestCase.assertNull
import org.junit.Assert.assertEquals
import org.junit.Test
class HowLongToBeatResultParserTest {
@Test
fun parseWithEmptyStringReturnsEmptyList() {
val result = HowLongToBeatResultParser.parse("")
assertEquals(0, result.size)
fun parseWithEmptyStringReturnsNull() {
val result = HowLongToBeatResultParser.parse("", Cartridge.UNKNOWN_CART)
assertNull(result)
}
@Test
@ -66,14 +68,22 @@ I/System.out: <div class="search_list_tidbit text_white shadow_text">Ma
<img alt="Box Art" src="/games/250px-Super_Mario_Land_2_box_art.jpg" />
</a>
""".trimIndent()
val result = HowLongToBeatResultParser.parse(html)
val result = HowLongToBeatResultParser.parse(html, Cartridge("DMG", "name", "DMG-CODE1"))!!
val smland = result[0]
val sm3dland = result[1]
assertEquals(3, result.size)
assertEquals("Super Mario Land", smland.title)
assertEquals("Super Mario 3D Land", sm3dland.title)
assertEquals("https://howlongtobeat.com/games/250px-Supermariolandboxart.jpg", smland.boxartUrl)
assertEquals("https://howlongtobeat.com/games/250px-Super-Mario-3D-Land-Logo.jpg", sm3dland.boxartUrl)
assertEquals(1.0, smland.howlong, 0.0)
assertEquals(6.5, sm3dland.howlong, 0.0)
assertEquals("DMG-CODE1", smland.cartCode)
assertEquals("DMG-CODE1", sm3dland.cartCode)
}
}

View File

@ -0,0 +1,120 @@
package be.kuleuven.howlongtobeat.model
import be.kuleuven.howlongtobeat.ImageRecognizer
import be.kuleuven.howlongtobeat.UnableToFindGameException
import be.kuleuven.howlongtobeat.cartridges.Cartridge
import be.kuleuven.howlongtobeat.cartridges.CartridgesRepository
import be.kuleuven.howlongtobeat.hltb.HLTBClient
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import java.util.concurrent.Executors
class GameFinderTest {
private lateinit var finder: GameFinder
@MockK
private lateinit var hltbClient: HLTBClient
@MockK
private lateinit var imageRecognizer: ImageRecognizer
@MockK
private lateinit var cartridgesRepository: CartridgesRepository
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
@Before fun setUp() {
MockKAnnotations.init(this)
finder = GameFinder(hltbClient, listOf(cartridgesRepository), imageRecognizer)
Dispatchers.setMain(dispatcher)
}
@After fun tearDown() {
Dispatchers.resetMain()
dispatcher.close()
}
@Test fun findGameBasedOnCameraSnap_UnrecognizableAccordingToGoogleVision_fails() = runBlocking {
coEvery { imageRecognizer.recognizeCartCode(any()) } returns null
try {
finder.findGameBasedOnCameraSnap(mockk()) {
}
Assert.fail("Expected exception to occur")
} catch(expected: UnableToFindGameException) {
Assert.assertEquals("No cart code in your pic found", expected.message)
}
}
@Test fun findGameBasedOnCameraSnap_UnknownCartridgeAccordingToDBs_fails() = runBlocking {
coEvery { imageRecognizer.recognizeCartCode(any()) } returns "DMG-MQ-EUR"
coEvery { cartridgesRepository.find("DMG-MQ-EUR") } returns null
try {
finder.findGameBasedOnCameraSnap(mockk()) {
}
Assert.fail("Expected exception to occur")
} catch(expected: UnableToFindGameException) {
Assert.assertEquals("DMG-MQ-EUR is an unknown game cart.", expected.message)
}
}
@Test fun findGameBasedOnCameraSnap_UnknownCartridgeAccordingToFirstDBButSecondFindsIt_returnsHltbResults() = runBlocking {
val secondCartridgeDb = mockk<CartridgesRepository>()
val cart = Cartridge("type", "Mario Land 356", "DMG-MQ-EUR")
coEvery { imageRecognizer.recognizeCartCode(any()) } returns cart.code
coEvery { cartridgesRepository.find(cart.code) } returns null
coEvery { secondCartridgeDb.find(cart.code) } returns cart
coEvery { hltbClient.find(cart) } returns listOf(HowLongToBeatResult(cart.title, cart.code, 34.5))
finder = GameFinder(hltbClient, listOf(cartridgesRepository, secondCartridgeDb), imageRecognizer)
val foundGames = finder.findGameBasedOnCameraSnap(mockk()) {
}
assertEquals(1, foundGames.size)
assertEquals(34.5, foundGames.single().howlong)
}
@Test fun findGameBasedOnCameraSnap_UnknownGameAccordingToHLTB_fails() = runBlocking {
val cart = Cartridge("type", "Mario Land 356", "DMG-MQ-EUR")
coEvery { imageRecognizer.recognizeCartCode(any()) } returns cart.code
coEvery { cartridgesRepository.find(cart.code) } returns cart
coEvery { hltbClient.find(cart) } returns null
try {
finder.findGameBasedOnCameraSnap(mockk()) {
}
Assert.fail("Expected exception to occur")
} catch(expected: UnableToFindGameException) {
Assert.assertEquals("HLTB does not know Mario Land 356", expected.message)
}
}
@Test fun findGameBasedOnCameraSnap_validGame_returnsHltbResults() = runBlocking {
val cart = Cartridge("type", "Mario Land 356", "DMG-MQ-EUR")
coEvery { imageRecognizer.recognizeCartCode(any()) } returns cart.code
coEvery { cartridgesRepository.find(cart.code) } returns cart
coEvery { hltbClient.find(cart) } returns listOf(HowLongToBeatResult(cart.title, cart.code, 34.5))
val foundGames = finder.findGameBasedOnCameraSnap(mockk()) {
}
assertEquals(1, foundGames.size)
assertEquals(34.5, foundGames.single().howlong)
}
}

View File

@ -18,4 +18,4 @@ android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
kotlin.code.style=official