diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1663009b3d98c1f01d4a62e3b77b8ee847155aff..8b02e2e3e2ea6f27fc2722fc04363eae387665ff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:converter-gson:2.9.0") implementation("androidx.security:security-crypto:1.1.0-alpha06") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1") implementation(libs.androidx.work.runtime.ktx) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3c98a8ba53033802c3db9594a1fabe427c682126..b53c17d0ec3d5a1e1e3b0544a10f47b17373a663 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,8 +1,14 @@ <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> + + <uses-feature + android:name="android.hardware.camera" + android:required="false" /> + <uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> + <uses-permission android:name="android.permission.CAMERA" /> <application android:allowBackup="true" diff --git a/app/src/main/java/com/example/myapplication/backendconnect/BackendService.kt b/app/src/main/java/com/example/myapplication/backendconnect/BackendService.kt index 39b709bdf2309cb61d0f7637d374c4f12bf2aa17..3d2a0819d409cfafeed7d6f485087855eccaf8c3 100644 --- a/app/src/main/java/com/example/myapplication/backendconnect/BackendService.kt +++ b/app/src/main/java/com/example/myapplication/backendconnect/BackendService.kt @@ -2,19 +2,24 @@ package com.example.myapplication.backendconnect import com.example.myapplication.model.auth.Token import com.example.myapplication.model.auth.UserCred +import com.example.myapplication.model.bill.BillResponse +import okhttp3.MultipartBody import retrofit2.Call import retrofit2.http.Body import retrofit2.http.Header +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part interface BackendService { @POST("auth/login") - fun login(@Body userCred: UserCred): Call<Token> + fun login(@Body data: Map<String, String>): Call<Token> @POST("auth/token") - fun tokenCheck(@Header("Authorization") token : String): Call<Token> + fun tokenCheck(@Header("Authorization") token : String): Call<UserCred> -// @POST("bill/upload") -// fun uploadBill(@Body bill: Bill): Call<BillResponse> + @Multipart + @POST("bill/upload") + fun uploadBill(@Header("Authorization") token: String , @Part file: MultipartBody.Part): Call<BillResponse> } \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/model/auth/UserCred.kt b/app/src/main/java/com/example/myapplication/model/auth/UserCred.kt index c46eb7c1d95418954d00efb8464c9b49f265f63d..5fcf2595515bcef81bb54866d97c771c384674e5 100644 --- a/app/src/main/java/com/example/myapplication/model/auth/UserCred.kt +++ b/app/src/main/java/com/example/myapplication/model/auth/UserCred.kt @@ -3,8 +3,10 @@ package com.example.myapplication.model.auth import com.google.gson.annotations.SerializedName data class UserCred ( - @SerializedName("email") - val email: String, - @SerializedName("password") - val password: String + @SerializedName("nim") + val nim: String, + @SerializedName("iat") + val iat: String, + @SerializedName("exp") + val exp: String ) \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/model/bill/BillResponse.kt b/app/src/main/java/com/example/myapplication/model/bill/BillResponse.kt new file mode 100644 index 0000000000000000000000000000000000000000..0121273f246c13f23b88512d39803b8e0cc41247 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/model/bill/BillResponse.kt @@ -0,0 +1,22 @@ +package com.example.myapplication.model.bill + +import com.google.gson.annotations.SerializedName + +data class BillResponse ( + @SerializedName("items") + val items: Items +) { + data class Items ( + @SerializedName("items") + val items: List<Item> + ) { + data class Item ( + @SerializedName("name") + val name: String, + @SerializedName("qty") + val quantity: Int, + @SerializedName("price") + val price: Double + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/repository/AuthRepository.kt b/app/src/main/java/com/example/myapplication/repository/AuthRepository.kt index 05d37051d471ef2bec300eab66cdc3d2501be94d..f519451746e7fce29eb1fb827a8dc6db7669c8bd 100644 --- a/app/src/main/java/com/example/myapplication/repository/AuthRepository.kt +++ b/app/src/main/java/com/example/myapplication/repository/AuthRepository.kt @@ -1,22 +1,32 @@ package com.example.myapplication.repository +import android.net.Uri import android.util.Log +import androidx.core.content.ContentProviderCompat.requireContext import com.example.myapplication.backendconnect.Client import com.example.myapplication.model.auth.Token import com.example.myapplication.model.auth.UserCred +import com.example.myapplication.model.bill.BillResponse import com.example.myapplication.util.EventBus import com.example.myapplication.util.LoginListener import com.example.myapplication.util.SecretPreference +import okhttp3.MediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody + import retrofit2.Call import retrofit2.Callback import retrofit2.Response +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream class AuthRepository ( private val secretPreference: SecretPreference) { fun loginRequest(email: String, password: String){ Log.d("Development", "Login request to backend service") - Client.connect.login(UserCred(email, password)).enqueue( + Client.connect.login(mapOf("email" to email, "password" to password)).enqueue( object : Callback<Token> { override fun onResponse(call: Call<Token>, response: Response<Token>) { Log.d("Development", "Response: ${response.body()}") @@ -52,8 +62,8 @@ class AuthRepository ( val token = secretPreference.getToken() ?: "" Client.connect.tokenCheck("Bearer $token").enqueue( - object : Callback<Token> { - override fun onResponse(call: Call<Token>, response: Response<Token>) { + object : Callback<UserCred> { + override fun onResponse(call: Call<UserCred>, response: Response<UserCred>) { Log.d("Development", "Response: ${response.body()}") if (response.isSuccessful) { Log.d("Development", "Token check success: Valid token") @@ -68,11 +78,40 @@ class AuthRepository ( } } - override fun onFailure(call: Call<Token>, t: Throwable) { + override fun onFailure(call: Call<UserCred>, t: Throwable) { Log.d("Development", "TOKEN CHECK FAILED, error on delivery : ${t.message}") } } ) } + fun uploadBillRequest(file: File) { + Log.d("Development", "Upload bill request to backend service") + + val token : String = secretPreference.getToken() ?: "" + val requestFile = RequestBody.create(MediaType.parse("image/jpeg"), file) + val body = MultipartBody.Part.createFormData("file", file.name, requestFile) + + Client.connect.uploadBill("Bearer $token", body).enqueue( + object : Callback<BillResponse> { + override fun onResponse(call: Call<BillResponse>, response: Response<BillResponse>) { + Log.d("Development", "Response: $response") + if (response.isSuccessful) { + Log.i("Development", "Upload Success, response: ${response.body()}") + EventBus.publish("UPLOAD_SUCCESS") + } else { + Log.d("Development", "UPLOAD FAILED, http code : ${response.code()}") + EventBus.publish("UPLOAD_FAIL") + } + } + + override fun onFailure(call: Call<BillResponse>, t: Throwable) { + Log.d("Development", "UPLOAD FAILED, error on delivery : ${t.message}") + EventBus.publish("UPLOAD_FAIL") + } + } + ) + } + + } \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/ui/dashboard/DashboardFragment.kt b/app/src/main/java/com/example/myapplication/ui/dashboard/DashboardFragment.kt index e417b48167f5c10e3e10ab5aacd415be5730a1fd..8258269c19efd914decbcc2cf49a0aee3d703cce 100644 --- a/app/src/main/java/com/example/myapplication/ui/dashboard/DashboardFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/dashboard/DashboardFragment.kt @@ -1,22 +1,102 @@ package com.example.myapplication.ui.dashboard +import android.Manifest +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.ImageFormat +import android.graphics.SurfaceTexture +import android.hardware.camera2.CameraCaptureSession +import android.hardware.camera2.CameraDevice +import android.hardware.camera2.CameraManager +import android.hardware.camera2.CaptureRequest +import android.hardware.camera2.params.OutputConfiguration +import android.hardware.camera2.params.SessionConfiguration +import android.media.ImageReader +import android.net.Uri import android.os.Bundle +import android.provider.MediaStore +import android.util.Log import android.view.LayoutInflater +import android.view.Surface +import android.view.TextureView import android.view.View import android.view.ViewGroup -import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider -import com.example.myapplication.databinding.FragmentDashboardBinding +import com.example.myapplication.databinding.FragmentScanBinding +import com.example.myapplication.repository.AuthRepository +import com.example.myapplication.util.SecretPreference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.concurrent.Executor class DashboardFragment : Fragment() { - private var _binding: FragmentDashboardBinding? = null + private var _binding: FragmentScanBinding? = null // This property is only valid between onCreateView and // onDestroyView. private val binding get() = _binding!! + private lateinit var cameraId: String + private lateinit var cameraDevice: CameraDevice + private lateinit var captureRequestBuilder : CaptureRequest.Builder + private lateinit var mainExecutor : Executor + private var cameraPermission: Boolean = false + private lateinit var fileImage: File + private lateinit var imagePath : String + + private lateinit var authRepository : AuthRepository + + private val stateCallBack = object : CameraDevice.StateCallback() { + override fun onOpened(camera: CameraDevice) { + cameraDevice = camera + startCamera() + } + + override fun onDisconnected(camera: CameraDevice) { + cameraDevice.close() + } + + override fun onError(camera: CameraDevice, error: Int) { + cameraDevice.close() + } + } + + private val surfaceTextureListener = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { + Log.i("Development", "Surface texture available") + startCamera() + } + + override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { + // Ignored, Camera does all the work for us + } + + override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { + return false + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) { + // Ignored + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + authRepository = AuthRepository(SecretPreference(requireContext())) + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -25,18 +105,216 @@ class DashboardFragment : Fragment() { val dashboardViewModel = ViewModelProvider(this).get(DashboardViewModel::class.java) - _binding = FragmentDashboardBinding.inflate(inflater, container, false) - val root: View = binding.root + _binding = FragmentScanBinding.inflate(inflater, container, false) - val textView: TextView = binding.textDashboard - dashboardViewModel.text.observe(viewLifecycleOwner) { - textView.text = it + cameraPermissionCheck() + + binding.buttonScan.setOnClickListener { + takePicture() + } + + binding.buttonSend.setOnClickListener { + if(::imagePath.isInitialized) { + CoroutineScope(Dispatchers.IO).launch { + val uri = Uri.parse(imagePath) + val parcelFileDescriptor = requireContext().contentResolver.openFileDescriptor(uri, "r") + val file = File.createTempFile("upload", null, requireContext().cacheDir) + val inputStream = FileInputStream(parcelFileDescriptor?.fileDescriptor) + val outputStream = FileOutputStream(file) + inputStream.copyTo(outputStream) + + authRepository.uploadBillRequest(file) + + parcelFileDescriptor?.close() + } + } else { + Log.w("Development", "Image path not initialized") + } + } + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + fileImage = File(requireContext().externalMediaDirs.first(), "\"${System.currentTimeMillis()}.jpg\"" ) + } + + override fun onResume() { + super.onResume() + if(cameraPermission) { + setupCamera() + binding.cameraPreview.surfaceTextureListener = surfaceTextureListener } - return root } override fun onDestroyView() { super.onDestroyView() _binding = null } + + // CAMERA + private fun cameraPermissionCheck() { + Log.i("Development", "Checking camera permission") + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) + == PackageManager.PERMISSION_DENIED) { + registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + // Permission is granted + Log.i("Development", "Camera permission granted") + cameraPermission = true + } else { + // Permission is denied + Log.w("Development", "Camera permission DENIED") + } + }.launch(Manifest.permission.CAMERA) + + } else { + cameraPermission = true + } + + if(cameraPermission) { + Log.i("Development", "Camera permission granted") + } + } + + private fun setupCamera() { + Log.i("Development", "Setting up camera") + if(ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED) { + val cameraManager = + requireContext().getSystemService(Context.CAMERA_SERVICE) as CameraManager + cameraId = cameraManager.cameraIdList[0] + Log.i("Development", "Camera ID: $cameraId") + cameraManager.openCamera(cameraId, stateCallBack, null) + Log.i("Development", "Camera opened") + } + } + @SuppressLint("Recycle") + private fun startCamera() { + val texture = binding.cameraPreview.surfaceTexture + if (texture != null) { + texture.setDefaultBufferSize(290, 420) + } else { + Log.e("Development", "Texture is null") + } + Log.i("Development", "Camera starting") + + val surface = Surface(texture) + captureRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) + captureRequestBuilder.addTarget(surface) + + cameraDevice.createCaptureSession(listOf(surface), object : CameraCaptureSession.StateCallback() { + override fun onConfigured(session: CameraCaptureSession) { + session.setRepeatingRequest(captureRequestBuilder.build(), null, null) + } + + override fun onConfigureFailed(session: CameraCaptureSession) { + Log.w("Development", "Camera configuration failed") + } + }, null) + +// val outputConfig : OutputConfiguration = OutputConfiguration(surface) +// mainExecutor = ContextCompat.getMainExecutor(requireContext()) +// +// val sessionConfig : SessionConfiguration = SessionConfiguration( +// SessionConfiguration.SESSION_REGULAR, +// listOf(outputConfig), +// mainExecutor, +// cameraCaptureStateCallback) +// +// cameraDevice.createCaptureSession(sessionConfig) + + } + + val REQUEST_IMAGE_CAPTURE = 1 + + private fun dispatchTakePictureIntent() { + val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + try { + startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE) + } catch (e: ActivityNotFoundException) { + // display error state to the user + } + } + + + private fun takePicture() { + val captureRequestBuilderTake = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE) + val imageReader = ImageReader.newInstance(290, 420, ImageFormat.JPEG, 1) + + imageReader.setOnImageAvailableListener({ reader -> + Log.i("Development", "image listener called") + reader.acquireNextImage().use { image -> + val buffer = image.planes[0].buffer + val bytes = ByteArray(buffer.remaining()) + buffer.get(bytes) + saveImage(bytes) + Log.i("Development", "Image saved") + } + }, null) + + + captureRequestBuilderTake.addTarget(imageReader.surface) + captureRequestBuilderTake.addTarget(Surface(binding.cameraPreview.surfaceTexture)) + + val outputConfig = OutputConfiguration(Surface(binding.cameraPreview.surfaceTexture)) + val outputConfigReader = OutputConfiguration(imageReader.surface) + + val sessionConfig = SessionConfiguration(SessionConfiguration.SESSION_REGULAR, + listOf(outputConfig, outputConfigReader), ContextCompat.getMainExecutor(requireContext()), object : CameraCaptureSession.StateCallback() { + override fun onConfigured(session: CameraCaptureSession) { + session.capture(captureRequestBuilderTake.build(), null, null) + } + + override fun onConfigureFailed(session: CameraCaptureSession) { + Log.e("Development", "Capture session configuration failed") + } + }) + + cameraDevice.createCaptureSession(sessionConfig) + } + + private fun saveImage(file: ByteArray) { + val resolver = requireContext().contentResolver + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, "Image_${System.currentTimeMillis()}.jpg") + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + } + + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + + uri?.let { + resolver.openOutputStream(it)?.use { outputStream -> + outputStream.write(file) + } + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + contentValues.clear() + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, contentValues, null, null) + + } + imagePath = it.toString() + Log.i("Development", "Image saved to $it") + } + + } + + + val cameraCaptureStateCallback = object : CameraCaptureSession.StateCallback() { + override fun onConfigured(session: CameraCaptureSession) { + session.setRepeatingRequest(captureRequestBuilder.build(), null, null) + } + + override fun onConfigureFailed(session: CameraCaptureSession) { + Log.w("Development", "Camera configuration failed") + } + } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_scan.xml b/app/src/main/res/layout/fragment_scan.xml new file mode 100644 index 0000000000000000000000000000000000000000..5901b3252192ff780eabb16591041da6ddc2f80b --- /dev/null +++ b/app/src/main/res/layout/fragment_scan.xml @@ -0,0 +1,40 @@ +<?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="match_parent" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + <TextureView + android:id="@+id/camera_preview" + android:layout_width="290dp" + android:layout_height="420dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_bias="0.311" /> + + <Button + android:id="@+id/button_scan" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="20dp" + android:text="Capture" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0.315" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/camera_preview" /> + + <Button + android:id="@+id/button_send" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Send" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0.254" + app:layout_constraintStart_toEndOf="@id/button_scan" + app:layout_constraintTop_toBottomOf="@id/camera_preview" + app:layout_constraintVertical_bias="0.12" /> +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file