hltb example: backlink hltb result to game list

This commit is contained in:
Wouter Groeneveld 2021-08-17 20:44:27 +02:00
parent 5b5810c35d
commit adde1e8e70
12 changed files with 94 additions and 26 deletions

View File

@ -4,15 +4,26 @@ import android.app.Activity
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.view.View
import androidx.core.view.isVisible
import java.net.URL
import kotlin.math.roundToInt import kotlin.math.roundToInt
fun Uri.toBitmap(activity: Activity): Bitmap { fun Uri.toBitmap(activity: Activity): Bitmap
return BitmapFactory.decodeStream(activity.contentResolver.openInputStream(this)) = BitmapFactory.decodeStream(activity.contentResolver.openInputStream(this))
}
fun URL.downloadAsImage(): Bitmap =
BitmapFactory.decodeStream(this.openConnection().getInputStream())
fun Bitmap.scaleToWidth(width: Int): Bitmap { fun Bitmap.scaleToWidth(width: Int): Bitmap {
val aspectRatio = this.width.toFloat() / this.height.toFloat() val aspectRatio = this.width.toFloat() / this.height.toFloat()
val height = (width / aspectRatio).roundToInt() val height = (width / aspectRatio).roundToInt()
return Bitmap.createScaledBitmap(this, width, height, false) return Bitmap.createScaledBitmap(this, width, height, false)
}
fun View.ensureVisible() {
if(!this.isVisible) {
this.visibility = View.VISIBLE
}
} }

View File

@ -10,7 +10,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import be.kuleuven.howlongtobeat.databinding.FragmentGamelistBinding import be.kuleuven.howlongtobeat.databinding.FragmentGamelistBinding
import be.kuleuven.howlongtobeat.model.Game import be.kuleuven.howlongtobeat.model.Game
import be.kuleuven.howlongtobeat.model.GameRepository import be.kuleuven.howlongtobeat.model.GameRepository
import be.kuleuven.howlongtobeat.model.room.GameRepositoryRoomImpl
class GameListFragment : Fragment(R.layout.fragment_gamelist) { class GameListFragment : Fragment(R.layout.fragment_gamelist) {
@ -28,7 +27,7 @@ class GameListFragment : Fragment(R.layout.fragment_gamelist) {
): View? { ): View? {
binding = FragmentGamelistBinding.inflate(layoutInflater) binding = FragmentGamelistBinding.inflate(layoutInflater)
main = activity as MainActivity main = activity as MainActivity
gameRepository = GameRepositoryRoomImpl(main.applicationContext) gameRepository = GameRepository.defaultImpl(main.applicationContext)
loadGames() loadGames()
adapter = GameListAdapter(gameList) adapter = GameListAdapter(gameList)

View File

@ -1,12 +1,12 @@
package be.kuleuven.howlongtobeat package be.kuleuven.howlongtobeat
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.fragment.app.findFragment
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -19,8 +19,11 @@ class HltbResultsAdapter(private val items: List<HowLongToBeatResult>) : Recycle
inner class HltbResultsViewHolder(currentItemView: View) : RecyclerView.ViewHolder(currentItemView) inner class HltbResultsViewHolder(currentItemView: View) : RecyclerView.ViewHolder(currentItemView)
private lateinit var parentFragment: HltbResultsFragment
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HltbResultsViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HltbResultsViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_hltbresult, parent, false) val view = LayoutInflater.from(parent.context).inflate(R.layout.item_hltbresult, parent, false)
parentFragment = parent.findFragment()
return HltbResultsViewHolder(view) return HltbResultsViewHolder(view)
} }
@ -28,6 +31,10 @@ class HltbResultsAdapter(private val items: List<HowLongToBeatResult>) : Recycle
val itm = items[position] val itm = items[position]
holder.itemView.apply { holder.itemView.apply {
setOnClickListener {
parentFragment.addResultToGameLibrary(itm)
}
findViewById<TextView>(R.id.txtHltbItemResult).text = itm.toString() findViewById<TextView>(R.id.txtHltbItemResult).text = itm.toString()
val boxArtView = findViewById<ImageView>(R.id.imgHltbItemResult) val boxArtView = findViewById<ImageView>(R.id.imgHltbItemResult)
@ -35,7 +42,7 @@ class HltbResultsAdapter(private val items: List<HowLongToBeatResult>) : Recycle
MainScope().launch{ MainScope().launch{
var art: Bitmap? = null var art: Bitmap? = null
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
art = BitmapFactory.decodeStream(itm.boxartUrl().openConnection().getInputStream()) art = itm.boxartUrl().downloadAsImage()
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
boxArtView.setImageBitmap(art) boxArtView.setImageBitmap(art)

View File

@ -5,13 +5,18 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import be.kuleuven.howlongtobeat.databinding.FragmentHltbresultsBinding import be.kuleuven.howlongtobeat.databinding.FragmentHltbresultsBinding
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult 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
class HltbResultsFragment : Fragment(R.layout.fragment_hltbresults) { class HltbResultsFragment : Fragment(R.layout.fragment_hltbresults) {
private lateinit var binding: FragmentHltbresultsBinding private lateinit var binding: FragmentHltbresultsBinding
private lateinit var adapter: HltbResultsAdapter private lateinit var adapter: HltbResultsAdapter
private lateinit var gameRepository: GameRepository
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -22,10 +27,18 @@ class HltbResultsFragment : Fragment(R.layout.fragment_hltbresults) {
val resultFromLoadingFragment = arguments?.getSerializable(HowLongToBeatResult.RESULT) as List<HowLongToBeatResult> val resultFromLoadingFragment = arguments?.getSerializable(HowLongToBeatResult.RESULT) as List<HowLongToBeatResult>
gameRepository = GameRepository.defaultImpl(requireContext())
adapter = HltbResultsAdapter(resultFromLoadingFragment) adapter = HltbResultsAdapter(resultFromLoadingFragment)
binding.rvHltbResult.adapter = adapter binding.rvHltbResult.adapter = adapter
binding.rvHltbResult.layoutManager = LinearLayoutManager(this.context) binding.rvHltbResult.layoutManager = LinearLayoutManager(this.context)
return binding.root return binding.root
} }
fun addResultToGameLibrary(hltbResult: HowLongToBeatResult) {
gameRepository.save(Game(hltbResult))
Snackbar.make(requireView(), "Added ${hltbResult.title} to library!", Snackbar.LENGTH_LONG).show()
findNavController().navigate(R.id.action_hltbResultsFragment_to_gameListFragment)
}
} }

View File

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

View File

@ -12,7 +12,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.content.PermissionChecker import androidx.core.content.PermissionChecker
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import be.kuleuven.howlongtobeat.cartridges.Cartridge import be.kuleuven.howlongtobeat.cartridges.Cartridge
@ -26,12 +25,11 @@ import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
class LoadingFragment : Fragment(R.layout.fragment_loading) { class LoadingFragment : Fragment(R.layout.fragment_loading) {
private lateinit var hltbClient: HLTBClient private lateinit var hltbClient: HLTBClient
private lateinit var cartRepo: CartridgesRepository private lateinit var cartRepo: CartridgesRepository
private lateinit var visionClient: GoogleVisionClient private lateinit var imageRecognizer: ImageRecognizer
private lateinit var cameraPermissionActivityResult: ActivityResultLauncher<String> private lateinit var cameraPermissionActivityResult: ActivityResultLauncher<String>
private lateinit var cameraActivityResult: ActivityResultLauncher<Uri> private lateinit var cameraActivityResult: ActivityResultLauncher<Uri>
@ -49,7 +47,7 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
main = activity as MainActivity main = activity as MainActivity
cartRepo = CartridgesRepository.fromAsset(main.applicationContext) cartRepo = CartridgesRepository.fromAsset(main.applicationContext)
visionClient = GoogleVisionClient() imageRecognizer = GoogleVisionClient()
hltbClient = HLTBClient(main.applicationContext) hltbClient = HLTBClient(main.applicationContext)
cameraActivityResult = registerForActivityResult(ActivityResultContracts.TakePicture(), this::cameraSnapTaken) cameraActivityResult = registerForActivityResult(ActivityResultContracts.TakePicture(), this::cameraSnapTaken)
@ -77,11 +75,12 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
} }
private suspend fun findGameBasedOnCameraSnap(pic: Bitmap) { private suspend fun findGameBasedOnCameraSnap(pic: Bitmap) {
progress("Unleashing Google Vision on the pic...") /*
val cartCode = visionClient.findCartCodeViaGoogleVision(pic) progress("Recognizing game cart from picture...")
val cartCode = imageRecognizer.recognizeCartCode(pic)
if (cartCode == null) { if (cartCode == null) {
errorInProgress("Unable to find a code in your pic. Retry?") errorInProgress("No cart code in your pic found. Retry?")
return return
} }
@ -89,16 +88,19 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
val foundCart = cartRepo.find(cartCode) val foundCart = cartRepo.find(cartCode)
if (foundCart == Cartridge.UNKNOWN_CART) { if (foundCart == Cartridge.UNKNOWN_CART) {
errorInProgress("$cartCode is an unknown game cartridge. Retry?") errorInProgress("$cartCode is an unknown game cart. Retry?")
return return
} }
progress("Valid cart code $cartCode, looking in HLTB...") progress("Valid cart code $cartCode, looking in HLTB...")
*/
val foundCart = Cartridge("DMG", "Super Mario Land", "DMG-something")
hltbClient.find(foundCart.title) { hltbClient.find(foundCart.title) {
Snackbar.make(requireView(), "Found ${it.size} game(s) for cart $cartCode", Snackbar.LENGTH_LONG).show() Snackbar.make(requireView(), "Found ${it.size} game(s) for cart ${foundCart.code}", Snackbar.LENGTH_LONG).show()
// TODO wat als geen hltb results gevonden? // TODO wat als geen hltb results gevonden?
val bundle = bundleOf(HowLongToBeatResult.RESULT to it, HowLongToBeatResult.CODE to cartCode) val bundle = bundleOf(HowLongToBeatResult.RESULT to it)
findNavController().navigate(R.id.action_loadingFragment_to_hltbResultsFragment, bundle) findNavController().navigate(R.id.action_loadingFragment_to_hltbResultsFragment, bundle)
} }
} }
@ -123,6 +125,7 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
} }
private fun createNewTempCameraFile() { private fun createNewTempCameraFile() {
// a <Provider/> should be present in the manifest file.
val tempFile = File.createTempFile("hltbCameraSnap", ".png", main.cacheDir).apply { val tempFile = File.createTempFile("hltbCameraSnap", ".png", main.cacheDir).apply {
createNewFile() createNewFile()
deleteOnExit() deleteOnExit()
@ -136,9 +139,7 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
} }
private fun progress(msg: String) { private fun progress(msg: String) {
if(!binding.indeterminateBar.isVisible) { binding.indeterminateBar.ensureVisible()
binding.indeterminateBar.visibility = View.VISIBLE
}
binding.txtLoading.text = msg binding.txtLoading.text = msg
} }

View File

@ -1,6 +1,7 @@
package be.kuleuven.howlongtobeat.google package be.kuleuven.howlongtobeat.google
import android.graphics.Bitmap import android.graphics.Bitmap
import be.kuleuven.howlongtobeat.ImageRecognizer
import be.kuleuven.howlongtobeat.asEncodedGoogleVisionImage import be.kuleuven.howlongtobeat.asEncodedGoogleVisionImage
import be.kuleuven.howlongtobeat.cartridges.Cartridge import be.kuleuven.howlongtobeat.cartridges.Cartridge
import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.http.javanet.NetHttpTransport
@ -14,7 +15,7 @@ import com.google.api.services.vision.v1.model.Feature
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext 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 // 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) private val vision = Vision.Builder(NetHttpTransport(), GsonFactory.getDefaultInstance(), null)
@ -22,7 +23,7 @@ class GoogleVisionClient {
.setApplicationName("How Long To Beat") .setApplicationName("How Long To Beat")
.build() .build()
suspend fun findCartCodeViaGoogleVision(cameraSnap: Bitmap): String? { private suspend fun findCartCodeViaGoogleVision(cameraSnap: Bitmap): String? {
var response: BatchAnnotateImagesResponse var response: BatchAnnotateImagesResponse
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val sml2Data = cameraSnap.asEncodedGoogleVisionImage() val sml2Data = cameraSnap.asEncodedGoogleVisionImage()
@ -51,4 +52,6 @@ class GoogleVisionClient {
}.firstOrNull() }.firstOrNull()
return gbId?.description ?: null return gbId?.description ?: null
} }
override suspend fun recognizeCartCode(image: Bitmap): String? = findCartCodeViaGoogleVision(image)
} }

View File

@ -7,7 +7,6 @@ import java.net.URL
data class HowLongToBeatResult(val title: String, val howlong: Double, val boxart: String = "") : java.io.Serializable { data class HowLongToBeatResult(val title: String, val howlong: Double, val boxart: String = "") : java.io.Serializable {
companion object { companion object {
const val RESULT = "HowLongToBeatResult" const val RESULT = "HowLongToBeatResult"
const val CODE = "CartCode"
} }
fun hasBoxart(): Boolean = boxart.startsWith(HLTBClient.DOMAIN) fun hasBoxart(): Boolean = boxart.startsWith(HLTBClient.DOMAIN)

View File

@ -3,6 +3,7 @@ package be.kuleuven.howlongtobeat.model
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult
@Entity @Entity
data class Game( data class Game(
@ -10,6 +11,10 @@ data class Game(
@ColumnInfo(name = "is_done") var isDone: Boolean = false, @ColumnInfo(name = "is_done") var isDone: Boolean = false,
@PrimaryKey(autoGenerate = true) var id: Int = 0) : java.io.Serializable { @PrimaryKey(autoGenerate = true) var id: Int = 0) : java.io.Serializable {
constructor(result: HowLongToBeatResult) : this(result.title)
// TODO more columns (platform? hours, paths of images?)
fun check() { fun check() {
isDone = true isDone = true
} }

View File

@ -1,8 +1,21 @@
package be.kuleuven.howlongtobeat.model package be.kuleuven.howlongtobeat.model
import android.content.Context
import be.kuleuven.howlongtobeat.model.room.GameRepositoryRoomImpl
interface GameRepository { 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 load(): List<Game>
fun save(items: List<Game>) fun save(game: Game)
fun overwrite(items: List<Game>)
} }

View File

@ -20,7 +20,13 @@ class GameRepositoryRoomImpl(appContext: Context) :
override fun load(): List<Game> = dao.query() override fun load(): List<Game> = dao.query()
override fun save(items: List<Game>) { 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. // You'll learn more about transactions in the database course in the 3rd academic year.
db.runInTransaction { db.runInTransaction {
dao.deleteAll() dao.deleteAll()

View File

@ -23,5 +23,9 @@
<fragment <fragment
android:id="@+id/hltbResultsFragment" android:id="@+id/hltbResultsFragment"
android:name="be.kuleuven.howlongtobeat.HltbResultsFragment" android:name="be.kuleuven.howlongtobeat.HltbResultsFragment"
android:label="HltbResultsFragment" /> android:label="HltbResultsFragment" >
<action
android:id="@+id/action_hltbResultsFragment_to_gameListFragment"
app:destination="@id/gameListFragment" />
</fragment>
</navigation> </navigation>