diff --git a/README.md b/README.md index 3a2ed69..a60e1c1 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,6 @@ Requirements - Due to the version of OpenCV that is used, this project requires Android 5.0 (lollipop) or newer to run. -- In order to capture and manipulate images, Open Note Scanner depends on having the OpenCV Manager application installed. - - If not installed, Open Note Scanner will ask to download it from https://github.com/ctodobom/OpenCV-3.1.0-Android or from the Google Play Store. - - How to Install -------------- diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca30cb6..011fbff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,22 +4,32 @@ plugins { } android { - compileSdk = 33 - buildToolsVersion = "33.0.0" + compileSdk = 36 namespace = "com.todobom.opennotescanner" - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + + buildFeatures { + buildConfig = true + } + splits { + abi { + isEnable = true + reset() + include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + isUniversalApk = true + } } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true } defaultConfig { applicationId = "com.todobom.opennotescanner" minSdk = 21 - targetSdk = 31 - versionCode = 36 - versionName = "1.0.36" + targetSdk = 36 + versionCode = 37 + versionNameSuffix = "alpha" + versionName = "1.0.37" } buildTypes { getByName("release") { @@ -45,28 +55,36 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + dependencies { - implementation(fileTree(mapOf("include" to listOf("*.jar"), "dir" to "libs"))) + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") - implementation("androidx.core:core-ktx:1.9.0") - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.10") + implementation("androidx.core:core-ktx:1.16.0") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.20") - implementation("androidx.exifinterface:exifinterface:1.3.6") + implementation("com.guolindev.permissionx:permissionx:1.8.1") + implementation("androidx.exifinterface:exifinterface:1.4.1") testImplementation("junit:junit:4.13.2") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("androidx.fragment:fragment-ktx:1.5.5") - implementation("com.google.android.material:material:1.8.0") - implementation("com.google.zxing:core:3.5.1") - implementation("com.github.ctodobom:OpenCV-3.1.0-Android:9e00ee4218ca0c9e60a905c9f09bf499f9dc5115") - implementation("us.feras.mdv:markdownview:1.1.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.fragment:fragment-ktx:1.8.8") + implementation("com.google.android.material:material:1.12.0") + implementation("com.google.zxing:core:3.5.3") + implementation("org.opencv:opencv:4.12.0") + implementation("io.noties.markwon:core:4.6.2") + implementation("io.noties.markwon:html:4.6.2") implementation("com.github.ctodobom:drag-select-recyclerview:0.3.4.ctodobom.sections") implementation("com.github.allgood:Android-Universal-Image-Loader:717a00c") implementation("com.github.ctodobom:FabToolbar:3c5f0e0ff1b6d5089e20b7da7157a604075ae943") - implementation("com.github.matomo-org:matomo-sdk-android:4.1.4") - implementation("com.github.MikeOrtiz:TouchImageView:3.3") + implementation("com.github.matomo-org:matomo-sdk-android:4.3.4") + implementation("com.github.MikeOrtiz:TouchImageView:3.7.1") - val itextpdf_version = "7.2.5" - implementation("com.itextpdf:kernel:$itextpdf_version") - implementation("com.itextpdf:layout:$itextpdf_version") - implementation("com.itextpdf:io:$itextpdf_version") + val itextpdfVersion = "9.2.0" + implementation("com.itextpdf:kernel:$itextpdfVersion") + implementation("com.itextpdf:layout:$itextpdfVersion") + implementation("com.itextpdf:io:$itextpdfVersion") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5c4f43e..5fbc920 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,13 +3,15 @@ + android:required="true" /> - - + + + + diff --git a/app/src/main/java/com/todobom/opennotescanner/FullScreenImageAdapter.kt b/app/src/main/java/com/todobom/opennotescanner/FullScreenImageAdapter.kt index 069261d..1139da5 100644 --- a/app/src/main/java/com/todobom/opennotescanner/FullScreenImageAdapter.kt +++ b/app/src/main/java/com/todobom/opennotescanner/FullScreenImageAdapter.kt @@ -2,6 +2,7 @@ package com.todobom.opennotescanner import android.app.Activity import android.content.Context +import android.net.Uri import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -17,13 +18,13 @@ import java.util.* */ class FullScreenImageAdapter( private val _activity: Activity, - private val _imagePaths: ArrayList + private val _imageUris: ArrayList ) : PagerAdapter() { private var maxTexture = 0 private var mImageLoader: ImageLoader? = null private var mTargetSize: ImageSize? = null override fun getCount(): Int { - return _imagePaths.size + return _imageUris.size } override fun isViewFromObject(view: View, `object`: Any): Boolean { @@ -37,7 +38,7 @@ class FullScreenImageAdapter( val viewLayout = inflater.inflate(R.layout.layout_fullscreen_image, container, false) imgDisplay = viewLayout.findViewById(R.id.imgDisplay) as TouchImageView - val imagePath = _imagePaths[position] + val imageUri = _imageUris[position] /* BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; @@ -62,13 +63,13 @@ class FullScreenImageAdapter( */ // imgDisplay.setImageBitmap(bitmap); - mImageLoader!!.displayImage("file:///$imagePath", imgDisplay, mTargetSize) + mImageLoader!!.displayImage(imageUri.toString(), imgDisplay, mTargetSize) container.addView(viewLayout) return viewLayout } - fun getPath(position: Int): String { - return _imagePaths[position] + fun getUri(position: Int): Uri { + return _imageUris[position] } override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { diff --git a/app/src/main/java/com/todobom/opennotescanner/FullScreenViewActivity.kt b/app/src/main/java/com/todobom/opennotescanner/FullScreenViewActivity.kt index 600d09c..631d44f 100644 --- a/app/src/main/java/com/todobom/opennotescanner/FullScreenViewActivity.kt +++ b/app/src/main/java/com/todobom/opennotescanner/FullScreenViewActivity.kt @@ -1,15 +1,19 @@ package com.todobom.opennotescanner import android.app.AlertDialog +import android.content.Context import android.content.DialogInterface import android.content.Intent +import android.net.Uri import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View +import android.webkit.MimeTypeMap import androidx.appcompat.app.AppCompatActivity import androidx.core.content.FileProvider +import androidx.core.net.toFile import androidx.viewpager.widget.ViewPager import androidx.viewpager.widget.ViewPager.OnPageChangeListener import com.nostra13.universalimageloader.core.ImageLoader @@ -20,7 +24,6 @@ import com.todobom.opennotescanner.helpers.Utils import com.todobom.opennotescanner.helpers.Utils.Companion.maxTextureSize import com.todobom.opennotescanner.helpers.Utils.Companion.removeImageFromGallery import com.todobom.opennotescanner.views.TagEditorFragment -import java.io.File class FullScreenViewActivity : AppCompatActivity() { private lateinit var utils: Utils @@ -83,7 +86,7 @@ class FullScreenViewActivity : AppCompatActivity() { private fun loadAdapter(): FullScreenImageAdapter { mViewPager.adapter = null - val adapter = FullScreenImageAdapter(this@FullScreenViewActivity, utils.filePaths) + val adapter = FullScreenImageAdapter(this@FullScreenViewActivity, utils.fileUris) adapter.setImageLoader(mImageLoader) adapter.setMaxTexture(mMaxTexture, mTargetSize) mViewPager.adapter = adapter @@ -127,10 +130,20 @@ class FullScreenViewActivity : AppCompatActivity() { return super.onOptionsItemSelected(item) } + private fun isPng(context: Context, uri: Uri): Boolean { + // First try MIME type + val type = context.contentResolver.getType(uri) + if (type == "image/png") return true + + // Fallback: try extension + val extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + return extension.equals("png", ignoreCase = true) + } + private fun tagImage() { val item = mViewPager.currentItem - val filePath = mAdapter.getPath(item) - if (filePath.endsWith(".png")) { + val fileUri = mAdapter.getUri(item) + if (isPng(this, fileUri)) { val builder = AlertDialog.Builder(this) builder.setTitle(R.string.format_not_supported) builder.setMessage(R.string.format_not_supported_message) @@ -139,32 +152,30 @@ class FullScreenViewActivity : AppCompatActivity() { alerta.show() return } - val fm = supportFragmentManager - val tagEditorDialog = TagEditorFragment() - tagEditorDialog.setFilePath(filePath) - tagEditorDialog.setRunOnDetach { } - tagEditorDialog.show(fm, "tageditor_view") + val tagEditorDialog = TagEditorFragment(fileUri) + tagEditorDialog.show(supportFragmentManager, "tageditor_view") } private fun deleteImage() { val item = mViewPager.currentItem - val filePath = mAdapter.getPath(item) - val photoFile = File(filePath) - photoFile.delete() - removeImageFromGallery(filePath, this) + val fileUri = mAdapter.getUri(item) + removeImageFromGallery(fileUri, this) loadAdapter() if (0 == mAdapter.count) finish() mViewPager.currentItem = item } fun shareImage() { - val pager = mViewPager - val item = pager.currentItem + val uri = mAdapter.getUri(mViewPager.currentItem) val shareIntent = Intent(Intent.ACTION_SEND) - shareIntent.type = "image/jpg" - val uri = FileProvider.getUriForFile(applicationContext, "$packageName.fileprovider", File(mAdapter.getPath(item))) - shareIntent.putExtra(Intent.EXTRA_STREAM, uri) - Log.d("Fullscreen", "uri $uri") + val shareUri = if (uri.scheme == "file") { + FileProvider.getUriForFile(applicationContext, "$packageName.fileprovider", uri.toFile()) + } else { + uri + } + shareIntent.type = this.contentResolver.getType(shareUri) + shareIntent.putExtra(Intent.EXTRA_STREAM, shareUri) + Log.d("Fullscreen", "uri $shareUri") startActivity(Intent.createChooser(shareIntent, getString(R.string.share_snackbar))) } } \ No newline at end of file diff --git a/app/src/main/java/com/todobom/opennotescanner/GalleryGridActivity.kt b/app/src/main/java/com/todobom/opennotescanner/GalleryGridActivity.kt index b9479b4..0fec4b6 100644 --- a/app/src/main/java/com/todobom/opennotescanner/GalleryGridActivity.kt +++ b/app/src/main/java/com/todobom/opennotescanner/GalleryGridActivity.kt @@ -16,6 +16,7 @@ import android.widget.ImageView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.content.FileProvider +import androidx.core.net.toFile import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.afollestad.dragselectrecyclerview.DragSelectRecyclerView @@ -77,11 +78,10 @@ class GalleryGridActivity : AppCompatActivity(), ClickListener, DragSelectRecycl setSelectionMode(i > 0) } - inner class ThumbAdapter(activity: GalleryGridActivity?, files: ArrayList) : DragSelectRecyclerViewAdapter() { - private val mCallback: ClickListener? - var itemList = ArrayList() - fun add(path: String) { - itemList.add(path) + inner class ThumbAdapter(val activity: GalleryGridActivity?, val fileUris: ArrayList) : DragSelectRecyclerViewAdapter() { + + init { + setSelectionListener(activity) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThumbViewHolder { @@ -91,17 +91,17 @@ class GalleryGridActivity : AppCompatActivity(), ClickListener, DragSelectRecycl override fun onBindViewHolder(holder: ThumbViewHolder, position: Int) { super.onBindViewHolder(holder, position) // this line is important! - val filename = itemList[position] - if (filename != holder.filename) { + val fileUri = fileUris[position] + if (fileUri != holder.fileUri) { // remove previous image holder.image.setImageBitmap(null) // Load image, decode it to Bitmap and return Bitmap to callback - mImageLoader.displayImage("file:///$filename", holder.image, mTargetSize) + mImageLoader.displayImage(fileUri.toString(), holder.image, mTargetSize) // holder.image.setImageBitmap(decodeSampledBitmapFromUri(filename, 220, 220)); - holder.filename = filename + holder.fileUri = fileUri } if (isIndexSelected(position)) { holder.image.setColorFilter(Color.argb(140, 0, 255, 0)) @@ -111,29 +111,29 @@ class GalleryGridActivity : AppCompatActivity(), ClickListener, DragSelectRecycl } override fun getItemCount(): Int { - return itemList.size + return fileUris.size } - val selectedFiles: ArrayList + val selectedFiles: ArrayList get() { - val selection = ArrayList() + val selection = ArrayList() for (i in selectedIndices) { - selection.add(itemList[i!!]) + selection.add(fileUris[i]) } return selection } inner class ThumbViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener, OnLongClickListener { val image: ImageView - var filename: String? = null + var fileUri: Uri? = null override fun onClick(v: View) { // Forwards to the adapter's constructor callback - mCallback?.onClick(adapterPosition) + activity?.onClick(adapterPosition) } override fun onLongClick(v: View): Boolean { // Forwards to the adapter's constructor callback - mCallback?.onLongClick(adapterPosition) + activity?.onLongClick(adapterPosition) return true } @@ -145,18 +145,10 @@ class GalleryGridActivity : AppCompatActivity(), ClickListener, DragSelectRecycl this.itemView.setOnLongClickListener(this) } } - - // Constructor takes click listener callback - init { - mCallback = activity - for (file in files) { - add(file) - } - setSelectionListener(activity) - } } var myThumbAdapter: ThumbAdapter? = null + public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mSharedPref = PreferenceManager.getDefaultSharedPreferences(this) @@ -170,7 +162,7 @@ class GalleryGridActivity : AppCompatActivity(), ClickListener, DragSelectRecycl mImageLoader = ImageLoader.getInstance() mImageLoader.init(config) mTargetSize = ImageSize(220, 220) // result Bitmap will be fit to this size - val ab = ArrayList() + val ab = ArrayList() myThumbAdapter = ThumbAdapter(this, ab) // new Utils(getApplicationContext()).getFilePaths();); recyclerView = findViewById(R.id.recyclerview) as DragSelectRecyclerView @@ -189,8 +181,7 @@ class GalleryGridActivity : AppCompatActivity(), ClickListener, DragSelectRecycl private fun reloadAdapter() { recyclerView.setAdapter(null) - // ArrayList ab = new ArrayList<>(); - myThumbAdapter = ThumbAdapter(this, Utils(applicationContext).filePaths) + myThumbAdapter = ThumbAdapter(this, Utils(applicationContext).fileUris) recyclerView.setAdapter(myThumbAdapter) recyclerView.invalidate() setSelectionMode(false) @@ -203,11 +194,8 @@ class GalleryGridActivity : AppCompatActivity(), ClickListener, DragSelectRecycl private fun deleteImage() { for (filePath in myThumbAdapter!!.selectedFiles) { - val photoFile = File(filePath) - if (photoFile.delete()) { - removeImageFromGallery(filePath, this) - Log.d(TAG, "Removed file: $filePath") - } + removeImageFromGallery(filePath, this) + Log.d(TAG, "Removed file: $filePath") } reloadAdapter() } @@ -263,14 +251,24 @@ class GalleryGridActivity : AppCompatActivity(), ClickListener, DragSelectRecycl } fun pdfExport() { - val pdfFilePath = mergeImagesToPdf(applicationContext, myThumbAdapter!!.selectedFiles) - if (pdfFilePath != null) { + var pdfFileUri = mergeImagesToPdf(applicationContext, myThumbAdapter!!.selectedFiles) + if (pdfFileUri != null) { + if (pdfFileUri.scheme == "file") { + // if pdf was written pre Android Q we need a FileProvider to allow access + // if MediaStore was used, the Uri can be shared and permissions are set up already + // not a fan of this, maybe should revert usage of MediaStore + pdfFileUri = FileProvider.getUriForFile(applicationContext, "$packageName.fileprovider", File(pdfFileUri.path!!)) + } else if (pdfFileUri.scheme != "content") { + // unrecognized type + Log.e(TAG, "Unsupported URI scheme in PDF Export: ${pdfFileUri.scheme}") + return + } + try { - val file = File(pdfFilePath) - val i = Intent(Intent.ACTION_VIEW, FileProvider.getUriForFile(applicationContext, - "$packageName.fileprovider", file)) - i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - startActivity(i) + startActivity(Intent(Intent.ACTION_VIEW).apply { + setDataAndType(pdfFileUri, "application/pdf") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }) } catch (e: ActivityNotFoundException) { Toast.makeText(applicationContext, "Cant Find Your File", Toast.LENGTH_LONG).show() } @@ -282,20 +280,28 @@ class GalleryGridActivity : AppCompatActivity(), ClickListener, DragSelectRecycl if (selectedFiles.size == 1) { /* Only one scanned document selected: ACTION_SEND intent */ val shareIntent = Intent(Intent.ACTION_SEND) - shareIntent.type = "image/jpg" - val uri = FileProvider.getUriForFile(applicationContext, "$packageName.fileprovider", File(selectedFiles[0])) - shareIntent.putExtra(Intent.EXTRA_STREAM, uri) - Log.d("GalleryGridActivity", "uri $uri") + val shareUri = if (selectedFiles[0].scheme == "file") { + FileProvider.getUriForFile(applicationContext, "$packageName.fileprovider", selectedFiles[0].toFile()) + } else { + selectedFiles[0] + } + shareIntent.type = this.contentResolver.getType(shareUri) + shareIntent.putExtra(Intent.EXTRA_STREAM, shareUri) + Log.d("GalleryGridActivity", "uri $shareUri") startActivity(Intent.createChooser(shareIntent, getString(R.string.share_snackbar))) } else { val filesUris = ArrayList() - for (i in myThumbAdapter!!.selectedFiles) { - val uri = FileProvider.getUriForFile(applicationContext, "$packageName.fileprovider", File(i)) - filesUris.add(uri) - Log.d("GalleryGridActivity", "uri $uri") + for (selectedUri in myThumbAdapter!!.selectedFiles) { + val shareUri = if (selectedFiles[0].scheme == "file") { + FileProvider.getUriForFile(applicationContext, "$packageName.fileprovider", selectedUri.toFile()) + } else { + selectedUri + } + filesUris.add(shareUri) + Log.d("GalleryGridActivity", "uri $shareUri") } val shareIntent = Intent(Intent.ACTION_SEND_MULTIPLE) - shareIntent.type = "image/jpg" + shareIntent.type = "image/jpg" // TODO: check mimetype shareIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, filesUris) startActivity(Intent.createChooser(shareIntent, getString(R.string.share_snackbar))) } diff --git a/app/src/main/java/com/todobom/opennotescanner/ImageProcessor.java b/app/src/main/java/com/todobom/opennotescanner/ImageProcessor.java index 8a01fa2..ec9eb81 100644 --- a/app/src/main/java/com/todobom/opennotescanner/ImageProcessor.java +++ b/app/src/main/java/com/todobom/opennotescanner/ImageProcessor.java @@ -6,7 +6,6 @@ import android.graphics.Paint; import android.graphics.Path; import android.graphics.drawable.shapes.PathShape; -import android.hardware.SensorManager; import android.os.Handler; import android.os.Looper; import android.os.Message; @@ -37,7 +36,6 @@ import org.opencv.core.Point; import org.opencv.core.Scalar; import org.opencv.core.Size; -import org.opencv.imgcodecs.Imgcodecs; import org.opencv.imgproc.Imgproc; import java.util.ArrayList; @@ -77,8 +75,8 @@ public ImageProcessor(Looper looper, OpenNoteScannerActivity mainActivity) { String docPageFormat = sharedPref.getString("document_page_format", "0"); mDocumentAspectRatio = 0; if (docPageFormat.equals("0.0001")) { - Float customPageWidth = Float.parseFloat(sharedPref.getString("custom_pageformat_width" , "0" )); - Float customPageHeight = Float.parseFloat(sharedPref.getString("custom_pageformat_height" , "0" )); + float customPageWidth = Float.parseFloat(sharedPref.getString("custom_pageformat_width" , "0" )); + float customPageHeight = Float.parseFloat(sharedPref.getString("custom_pageformat_height" , "0" )); if (customPageWidth > 0 && customPageHeight > 0) { mDocumentAspectRatio = customPageHeight / customPageWidth; } @@ -95,7 +93,7 @@ public void handleMessage ( Message msg ) { String command = obj.getCommand(); - Log.d(TAG, "Message Received: " + command + " - " + obj.getObj().toString() ); + Log.v(TAG, "Message Received: " + command + " - " + obj.getObj().toString() ); if ( command.equals("previewFrame")) { processPreviewFrame((PreviewFrame) obj.getObj()); @@ -123,7 +121,7 @@ private void processPreviewFrame( PreviewFrame previewFrame ) { } boolean qrOk = false; - String currentQR=null; + String currentQR = null; for (Result result: results) { String qrText = result.getText(); @@ -140,10 +138,10 @@ private void processPreviewFrame( PreviewFrame previewFrame ) { boolean autoMode = previewFrame.isAutoMode(); boolean previewOnly = previewFrame.isPreviewOnly(); + // request picture if document is detected and either scan button is clicked and not in auto mode or qr code is detected in auto mode + // FIXME: consider simplifying this. isPreviewOnly contains isAutoMode, e.g if autoMode is true, isPreviewOnly will always be false if ( detectPreviewDocument(frame) && ( (!autoMode && !previewOnly ) || ( autoMode && qrOk ) ) ) { - mMainActivity.waitSpinnerVisible(); - mMainActivity.requestPicture(); if (qrOk) { @@ -154,22 +152,17 @@ private void processPreviewFrame( PreviewFrame previewFrame ) { frame.release(); mMainActivity.setImageProcessorBusy(false); - } public void processPicture( Mat picture ) { - - Mat img = Imgcodecs.imdecode(picture, Imgcodecs.CV_LOAD_IMAGE_UNCHANGED); - picture.release(); - - Log.d(TAG, "processPicture - imported image " + img.size().width + "x" + img.size().height); + Log.d(TAG, "processPicture - imported image " + picture.size().width + "x" + picture.size().height); if (mBugRotate) { - Core.flip(img, img, 1 ); - Core.flip(img, img, 0 ); + Core.flip(picture, picture, 1 ); + Core.flip(picture, picture, 0 ); } - ScannedDocument doc = detectDocument(img); + ScannedDocument doc = detectDocument(picture); mMainActivity.saveDocument(doc); doc.release(); @@ -252,7 +245,7 @@ private boolean detectPreviewDocument(Mat inputRgba) { drawDocumentBox(mPreviewPoints, mPreviewSize); - Log.d(TAG, quad.getPoints()[0].toString() + " , " + quad.getPoints()[1].toString() + " , " + quad.getPoints()[2].toString() + " , " + quad.getPoints()[3].toString()); +// Log.d(TAG, quad.getPoints()[0].toString() + " , " + quad.getPoints()[1].toString() + " , " + quad.getPoints()[2].toString() + " , " + quad.getPoints()[3].toString()); return true; diff --git a/app/src/main/java/com/todobom/opennotescanner/OpenNoteScannerActivity.kt b/app/src/main/java/com/todobom/opennotescanner/OpenNoteScannerActivity.kt index 7363744..0baf142 100644 --- a/app/src/main/java/com/todobom/opennotescanner/OpenNoteScannerActivity.kt +++ b/app/src/main/java/com/todobom/opennotescanner/OpenNoteScannerActivity.kt @@ -3,18 +3,19 @@ package com.todobom.opennotescanner import android.Manifest import android.annotation.SuppressLint import android.app.AlertDialog +import android.content.ContentValues import android.content.Context import android.content.DialogInterface import android.content.Intent import android.content.SharedPreferences -import android.content.pm.PackageManager import android.content.res.ColorStateList +import android.graphics.ImageFormat import android.graphics.Point import android.graphics.Rect import android.hardware.* import android.hardware.Camera.* import android.media.AudioManager -import android.media.MediaPlayer +import android.media.MediaActionSound import android.net.Uri import android.os.* import android.preference.PreferenceManager @@ -26,22 +27,22 @@ import android.widget.ImageView import android.widget.RelativeLayout import android.widget.Toast import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat import androidx.exifinterface.media.ExifInterface import com.github.fafaldo.fabtoolbar.widget.FABToolbarLayout import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.navigation.NavigationView +import com.permissionx.guolindev.PermissionX import com.todobom.opennotescanner.helpers.* import com.todobom.opennotescanner.helpers.ScanTopicDialogFragment.SetTopicDialogListener import com.todobom.opennotescanner.views.HUDCanvasView import org.matomo.sdk.Tracker import org.matomo.sdk.extra.TrackHelper -import org.opencv.android.BaseLoaderCallback import org.opencv.android.OpenCVLoader import org.opencv.core.Core import org.opencv.core.CvType import org.opencv.core.Mat +import org.opencv.core.MatOfByte +import org.opencv.core.MatOfInt import org.opencv.core.Size import org.opencv.imgcodecs.Imgcodecs import org.opencv.imgproc.Imgproc @@ -78,7 +79,7 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation } private var mVisible = false private val mHideRunnable = Runnable { hide() } - private var _shootMP: MediaPlayer? = null + private var mediaActionSound: MediaActionSound? = null private var safeToTakePicture = false private lateinit var scanDocButton: Button private lateinit var mImageThread: HandlerThread @@ -194,37 +195,46 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation } fun setFlash(stateFlash: Boolean): Boolean { - val pm = packageManager val camera = mCamera ?: return false - if (pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_FLASH)) { - val par = camera.parameters - par.flashMode = if (stateFlash) Camera.Parameters.FLASH_MODE_TORCH else Camera.Parameters.FLASH_MODE_OFF - camera.parameters = par + val flashModes = camera.parameters.supportedFlashModes + if (flashModes != null && flashModes.contains(Camera.Parameters.FLASH_MODE_TORCH)) { + val parameters = camera.parameters + parameters.flashMode = if (stateFlash) Camera.Parameters.FLASH_MODE_TORCH else Camera.Parameters.FLASH_MODE_OFF + camera.parameters = parameters Log.d(TAG, "flash: " + if (stateFlash) "on" else "off") return stateFlash } + + Log.d(TAG, "flash not available") return false } - private fun checkResumePermissions() { - if (ContextCompat.checkSelfPermission(this, - Manifest.permission.CAMERA) - != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), - RESUME_PERMISSIONS_REQUEST_CAMERA) + private fun grantPermissions() { + val permissionsToRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // new version will use MediaStore or SAF, does not need permissions + listOf(Manifest.permission.CAMERA) } else { - enableCameraView() + // TODO: can we move this to use SAF / MediaStore too ? + listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.CAMERA) } - } - private fun checkCreatePermissions() { - if (ContextCompat.checkSelfPermission(this, - Manifest.permission.WRITE_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), - MY_PERMISSIONS_REQUEST_WRITE) - } + PermissionX.init(this) + .permissions(permissionsToRequest) + .onExplainRequestReason { scope, deniedList -> + scope.showRequestReasonDialog(deniedList, getString(R.string.permission_explain_request_reason_all), getString(R.string.ok)) + } + .onForwardToSettings { scope, deniedList -> + scope.showForwardToSettingsDialog(deniedList, getString(R.string.permission_forward_reason_all), getString(R.string.ok)) + } + .explainReasonBeforeRequest() + .request { allGranted, _, deniedList -> + if (allGranted) { + enableCameraView() + } else { + // PermissionX will always prompt or redirect to settings if permissions are not granted. + } + } } fun turnCameraOn() { @@ -243,29 +253,6 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation } } - @SuppressLint("MissingSuperCall") - override fun onRequestPermissionsResult(requestCode: Int, - permissions: Array, grantResults: IntArray) { - when (requestCode) { - CREATE_PERMISSIONS_REQUEST_CAMERA -> { - - // If request is cancelled, the result arrays are empty. - if (grantResults.size > 0 - && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - turnCameraOn() - } - } - RESUME_PERMISSIONS_REQUEST_CAMERA -> { - - // If request is cancelled, the result arrays are empty. - if (grantResults.size > 0 - && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - enableCameraView() - } - } - } - } - override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) @@ -313,23 +300,11 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation mHideHandler.postDelayed(mHideRunnable, delayMillis.toLong()) } - private val mLoaderCallback: BaseLoaderCallback = object : BaseLoaderCallback(this) { - override fun onManagerConnected(status: Int) { - when (status) { - SUCCESS -> { - checkResumePermissions() - } - else -> { - Log.d(TAG, "opencvstatus: $status") - super.onManagerConnected(status) - } - } - } - } - public override fun onResume() { super.onResume() + grantPermissions() + sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.also { accelerometer -> sensorManager.registerListener( this, @@ -357,8 +332,15 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation for (build in Build.SUPPORTED_ABIS) { Log.d(TAG, "myBuild $build") } - checkCreatePermissions() - CustomOpenCVLoader.initAsync(OpenCVLoader.OPENCV_VERSION_3_1_0, this, mLoaderCallback) + + if (OpenCVLoader.initLocal()) { + Log.i(TAG, "OpenCV loaded successfully"); + } else { + Log.e(TAG, "OpenCV initialization failed!"); + (Toast.makeText(this, "OpenCV initialization failed!", Toast.LENGTH_LONG)).show(); + return; + } + //TODO these should go in the variable's creation mImageThread = HandlerThread("Worker Thread") mImageThread.start() @@ -390,6 +372,9 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation // Don't receive any more updates from either sensor. sensorManager.unregisterListener(this) + + mediaActionSound?.release() + mediaActionSound = null } val resolutionList: List @@ -467,30 +452,37 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation fun setFocusParameters() { val camera = mCamera ?: return - val param: Camera.Parameters = camera.parameters - val pm = packageManager - if (pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_AUTOFOCUS)) { - try { - camera.setAutoFocusMoveCallback { start, _ -> - mFocused = !start - Log.d(TAG, "focusMoving: $mFocused") - } - } catch (e: Exception) { - Log.d(TAG, "failed setting AutoFocusMoveCallback") - } - param.focusMode = Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE + + val parameters = camera.parameters + val supportedFocusModes = camera.parameters.getSupportedFocusModes() + if (supportedFocusModes != null && supportedFocusModes.contains(Camera.Parameters.FOCUS_MODE_AUTO)) { + parameters.focusMode = Camera.Parameters.FOCUS_MODE_AUTO + } else if (supportedFocusModes != null && supportedFocusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { + // fallback + parameters.focusMode = Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE val targetFocusRect = Rect(-500, -500, 500, 500) val focusList: MutableList = ArrayList() val focusArea = Camera.Area(targetFocusRect, 1000) focusList.add(focusArea) - param.focusAreas = focusList - param.meteringAreas = focusList - camera.parameters = param - Log.d(TAG, "enabling autofocus") + parameters.focusAreas = focusList + parameters.meteringAreas = focusList } else { mFocused = true Log.d(TAG, "autofocus not available") + return } + + try { + camera.setAutoFocusMoveCallback { start, _ -> + mFocused = !start + Log.d(TAG, "focusMoving: $mFocused") + } + } catch (e: Exception) { + Log.d(TAG, "failed setting AutoFocusMoveCallback") + } + + Log.d(TAG, "enabling autofocus") + camera.parameters = parameters } override fun surfaceCreated(holder: SurfaceHolder) { @@ -502,8 +494,8 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation } mCamera = camera - val param: Camera.Parameters - param = camera.getParameters() + val param = camera.getParameters() + param.pictureFormat = ImageFormat.JPEG val pSize = maxPreviewResolution param.setPreviewSize(pSize!!.width, pSize.height) val previewRatio = pSize.width.toFloat() / pSize.height @@ -539,7 +531,7 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation this.mDocumentAspectRatio = docPageFormat!!.toFloat().toDouble() } - var hotArea = Utils.getHotArea(pSize.width, pSize.height, this) + val hotArea = Utils.getHotArea(pSize.width, pSize.height, this) hotAreaSpaceWidth = hotArea!![1] hotAreaSpaceHeight = hotArea!![0] @@ -569,10 +561,7 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation param.setPictureSize(maxRes.width, maxRes.height) Log.d(TAG, "max supported picture resolution: " + maxRes.width + "x" + maxRes.height) } - val pm = packageManager - if (pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_FLASH)) { - param.flashMode = if (mFlashMode) Camera.Parameters.FLASH_MODE_TORCH else Camera.Parameters.FLASH_MODE_OFF - } + camera.setParameters(param) mBugRotate = mSharedPref.getBoolean("bug_rotate", false) if (mBugRotate) { @@ -619,7 +608,7 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation override fun onPreviewFrame(data: ByteArray, camera: Camera) { val pictureSize = camera.parameters.previewSize - Log.d(TAG, "onPreviewFrame - received image " + pictureSize.width + "x" + pictureSize.height + Log.v(TAG, "onPreviewFrame - received image " + pictureSize.width + "x" + pictureSize.height + " focused: " + mFocused + " imageprocessor: " + if (imageProcessorBusy) "busy" else "available") if (mFocused && !imageProcessorBusy) { setImageProcessorBusy(true) @@ -649,7 +638,11 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation if (safeToTakePicture) { runOnUiThread(resetShutterColor) safeToTakePicture = false - camera.takePicture(null, null, mThis) + try { + camera.takePicture(null, null, mThis) + } catch (_: java.lang.Exception) { + Log.e(TAG, "failed to take picture") + } return true } return false @@ -658,19 +651,41 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation override fun onPictureTaken(data: ByteArray, camera: Camera) { shootSound() setFocusParameters() - val pictureSize = camera.parameters.pictureSize - Log.d(TAG, "onPictureTaken - received image " + pictureSize.width + "x" + pictureSize.height) - mat = Mat(Size(pictureSize.width.toDouble(), pictureSize.height.toDouble()), CvType.CV_8U).also { - it.put(0, 0, data) + Log.d(TAG, "onPictureTaken - received ${data.size} bytes") + + val encodedMat = MatOfByte(*data) // or MatOfByte(data) + val decodedMat: Mat + try { + decodedMat = Imgcodecs.imdecode(encodedMat, Imgcodecs.IMREAD_UNCHANGED) + if (decodedMat.empty()) { + Log.e(TAG, "Failed to decode image from data byte array.") + refreshCamera() // Or some other error recovery + safeToTakePicture = true + return + } + } catch (e: Exception) { + Log.e(TAG, "Exception while decoding image: ${e.message}") + refreshCamera() + safeToTakePicture = true + return + } finally { + encodedMat.release() // Release the temporary MatOfByte } + Log.d(TAG, "Decoded image: ${decodedMat.width()}x${decodedMat.height()}, type: ${CvType.typeToString(decodedMat.type())}") + + // Store the decoded Mat to be used by issueProcessingOfTakenPicture + mat = decodedMat + if (mSharedPref.getBoolean("custom_scan_topic", false)) { val fm = supportFragmentManager val scanTopicDialogFragment = ScanTopicDialogFragment() scanTopicDialogFragment.show(fm, getString(R.string.scan_topic_dialog_title)) - return + // Note: if custom_scan_topic is true, issueProcessingOfTakenPicture + // will be called later by onFinishTopicDialog. 'this.mat' must hold the decoded image. + } else { + issueProcessingOfTakenPicture() } - issueProcessingOfTakenPicture() } override fun onFinishTopicDialog(inputText: String?) { @@ -686,7 +701,7 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation } fun sendImageProcessorMessage(messageText: String, obj: Any?) { - Log.d(TAG, "sending message to ImageProcessor: " + messageText + " - " + obj.toString()) + Log.v(TAG, "sending message to ImageProcessor: $messageText - $obj") val msg = mImageProcessor.obtainMessage() msg.obj = OpenNoteMessage(messageText, obj) mImageProcessor.sendMessage(msg) @@ -694,119 +709,201 @@ class OpenNoteScannerActivity : AppCompatActivity(), NavigationView.OnNavigation fun saveDocument(scannedDocument: ScannedDocument) { val doc = scannedDocument.processed ?: scannedDocument.original + val intent = intent - val fileName: String - var isIntent = false - var fileUri: Uri? = null - var imgSuffix = ".jpg" - if (mSharedPref.getBoolean("save_png", false)) { - imgSuffix = ".png" - } - if (intent.action == "android.media.action.IMAGE_CAPTURE") { - fileUri = intent.getParcelableExtra(MediaStore.EXTRA_OUTPUT) as Uri - Log.d(TAG, "intent uri: $fileUri") - fileName = try { - File.createTempFile("onsFile", imgSuffix, this.cacheDir).path - } catch (e: IOException) { - e.printStackTrace() - return - } - isIntent = true + val isIntentCapture = intent.action == "android.media.action.IMAGE_CAPTURE" + val outputUriFromIntent = if (isIntentCapture) { + intent.getParcelableExtra(MediaStore.EXTRA_OUTPUT) } else { - val folderName = mSharedPref.getString("storage_folder", "OpenNoteScanner") - val folder = File(Environment.getExternalStorageDirectory().toString(), "/$folderName") - if (!folder.exists()) { - folder.mkdirs() - Log.d(TAG, "wrote: created folder " + folder.path) - } - fileName = createFileName(imgSuffix, folderName) + null + } + + val imageSuffix = if (mSharedPref.getBoolean("save_png", false)) ".png" else ".jpg" + val mimeType = if (imageSuffix == ".png") "image/png" else "image/jpeg" + + val encodingParams = MatOfInt() + if (imageSuffix == ".jpg") { + encodingParams.fromArray(Imgcodecs.IMWRITE_JPEG_QUALITY, mSharedPref.getInt("jpeg_quality", 95)) // Example: get quality from prefs + } else { + encodingParams.fromArray(Imgcodecs.IMWRITE_PNG_COMPRESSION, mSharedPref.getInt("png_compression", 6)) // Example: get compression from prefs } - val endDoc = Mat(java.lang.Double.valueOf(doc.size().width).toInt(), - java.lang.Double.valueOf(doc.size().height).toInt(), CvType.CV_8UC4) - Core.flip(doc.t(), endDoc, 1) - Imgcodecs.imwrite(fileName, endDoc) - endDoc.release() + + val timeStamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.getDefault()).format(Date()) + var displayName = "DOC-$timeStamp$imageSuffix" + if (scanTopic != null) { + displayName = "$scanTopic-$displayName" + } + val customFolderName = mSharedPref.getString("storage_folder", "OpenNoteScanner") ?: "OpenNoteScanner" + + var savedFileUri: Uri? = null + var preQFilePath: String? = null // pre android Q file path + try { - val exif = ExifInterface(fileName) - exif.setAttribute("UserComment", "Generated using Open Note Scanner") - val nowFormatted = mDateFormat.format(Date().time) - exif.setAttribute(ExifInterface.TAG_DATETIME, nowFormatted) - exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, nowFormatted) - exif.setAttribute("Software", "OpenNoteScanner " + BuildConfig.VERSION_NAME + " https://goo.gl/2JwEPq") - exif.saveAttributes() - } catch (e: IOException) { - e.printStackTrace() - } - if (isIntent && fileUri != null) { - var inputStream: InputStream? = null - var realOutputStream: OutputStream? = null - try { - inputStream = FileInputStream(fileName) - realOutputStream = this.contentResolver.openOutputStream(fileUri) - // Transfer bytes from in to out - val buffer = ByteArray(1024) - var len: Int - while (inputStream.read(buffer).also { len = it } > 0) { - realOutputStream!!.write(buffer, 0, len) + // if intent has no target uri, we just handle it as if it was a normal document scanned from the app + if (isIntentCapture && outputUriFromIntent != null) { + // this does not create any MediaStore entries, caller has to do that + // I also need to find the use case of calling the app via an intent, to better understand how to handle this case + savedFileUri = outputUriFromIntent + } else { + // Saving to gallery (MediaStore) + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1) // Mark as pending until written + if (customFolderName.isNotBlank()) { + contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + File.separator + customFolderName) + } else { + contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) + } + val imageCollection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + savedFileUri = contentResolver.insert(imageCollection, contentValues) + } else { + val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + val targetDir = if (customFolderName.isNotBlank()) { + File(picturesDir, customFolderName) + } else { + picturesDir + } + + if (!targetDir.exists()) { + if (!targetDir.mkdirs()) { + Log.e(TAG, "Failed to create directory: ${targetDir.absolutePath}") + // Fallback to default pictures directory if folder creation fails + preQFilePath = File(picturesDir, displayName).absolutePath + } else { + preQFilePath = File(targetDir, displayName).absolutePath + } + } else { + preQFilePath = File(targetDir, displayName).absolutePath + } + contentValues.put(MediaStore.Images.Media.DATA, preQFilePath) + // For pre-Q, insert into the legacy external content URI + savedFileUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + } + } + + if (savedFileUri == null) { + // insert failed + if (isIntentCapture) { + setResult(RESULT_CANCELED) + finish() } - } catch (e: FileNotFoundException) { - e.printStackTrace() - return - } catch (e: IOException) { - e.printStackTrace() return - } finally { + } + + savedFileUri.let { uri -> + contentResolver.openOutputStream(uri)?.use { out -> + val endDoc = Mat() + Core.flip(doc.t(), endDoc, 1) + + // Convert Mat to byte array + val matOfByte = org.opencv.core.MatOfByte() + val successEncode = Imgcodecs.imencode(imageSuffix, endDoc, matOfByte, encodingParams) + endDoc.release() // Release the temporary transformed Mat + + if (!successEncode) { + throw IOException("Failed to encode Mat to $imageSuffix") + } + val imageBytes = matOfByte.toArray() + matOfByte.release() + + out.write(imageBytes) + Log.d(TAG, "Successfully wrote image data.") + } ?: throw IOException("Failed to open created Media File.") + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !isIntentCapture) { + val updateDetails = ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) // Mark as complete + } + contentResolver.update(savedFileUri, updateDetails, null, null) + } + + if (mimeType == "image/jpeg") { try { - inputStream!!.close() - realOutputStream!!.close() - } catch (e: IOException) { - e.printStackTrace() + contentResolver.openFileDescriptor(savedFileUri, "rw")?.use { pfd -> + val exif = ExifInterface(pfd.fileDescriptor) + val nowFormatted = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) // Using a more EXIF-friendly date format + exif.setAttribute(ExifInterface.TAG_DATETIME, nowFormatted) + exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, nowFormatted) + exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, nowFormatted) + exif.setAttribute(ExifInterface.TAG_SOFTWARE, "OpenNoteScanner " + BuildConfig.VERSION_NAME + " https://goo.gl/2JwEPq") + exif.setAttribute("UserComment", "Generated using Open Note Scanner") + exif.saveAttributes() + Log.d(TAG, "Exif data written to MediaStore URI.") + } + } catch (e: Exception) { + Log.e(TAG, "Error writing Exif to MediaStore URI: ${e.message}", e) } } - } - Log.d(TAG, "wrote: $fileName") - if (isIntent) { - File(fileName).delete() - setResult(RESULT_OK, intent) - finish() - } else { - animateDocument(fileName, scannedDocument) - Utils.addImageToGallery(fileName, this) - } - // Record goal "PictureTaken" - TrackHelper.track().event("Picture", "PictureTaken").with(tracker) - refreshCamera() - } + if (isIntentCapture) { + if (outputUriFromIntent != null) { + setResult(RESULT_OK, intent) + } else { + setResult(RESULT_OK) + } + finish() + } else { + Log.d(TAG, "Document saved to MediaStore: $savedFileUri") + animateDocument(savedFileUri, scannedDocument) - private fun createFileName(imgSuffix: String, folderName: String?): String { - var fileName: String - fileName = (Environment.getExternalStorageDirectory().toString() - + "/" + folderName + "/") - if (scanTopic != null) { - fileName += "$scanTopic-" + TrackHelper.track().event("Picture", "PictureTaken").with(tracker) + refreshCamera() + } + + } catch (e: Exception) { + Log.e(TAG, "Error saving document: ${e.message}", e) + if (savedFileUri != null) { + try { + contentResolver.delete(savedFileUri, null, null) + Log.w(TAG, "Attempted to delete MediaStore entry due to error: $savedFileUri") + // For Pre-Q, if preQFilePath is not null, also attempt to delete the physical file + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && preQFilePath != null) { + val physicalFile = File(preQFilePath) + if (physicalFile.exists()) { + if (physicalFile.delete()) { + Log.w(TAG, "Deleted physical file (pre-Q) due to error: $preQFilePath") + } else { + Log.e(TAG, "Failed to delete physical file (pre-Q) due to error: $preQFilePath") + } + } + } + } catch (deleteEx: Exception) { + Log.e(TAG, "Error during cleanup of MediaStore entry or file: ${deleteEx.message}") + } + } + if (isIntentCapture) { + setResult(RESULT_CANCELED) + finish() + } else { + // TODO: show error + refreshCamera() + } + } finally { + encodingParams.release() } - fileName += ("DOC-" - + SimpleDateFormat("yyyyMMdd-HHmmss").format(Date()) - + imgSuffix) - return fileName } - private fun animateDocument(filename: String, quadrilateral: ScannedDocument) { - val runnable = AnimationRunnable(this, filename, quadrilateral) + private fun animateDocument(documentUri: Uri, quadrilateral: ScannedDocument) { + val runnable = AnimationRunnable(this, documentUri, quadrilateral) runOnUiThread(runnable) } private fun shootSound() { - val meng = getSystemService(AUDIO_SERVICE) as AudioManager - val volume = meng.getStreamVolume(AudioManager.STREAM_NOTIFICATION) + val am = getSystemService(AUDIO_SERVICE) as AudioManager + val volume = am.getStreamVolume(AudioManager.STREAM_NOTIFICATION) if (volume != 0) { - if (_shootMP == null) { - _shootMP = MediaPlayer.create(this, Uri.parse("file:///system/media/audio/ui/camera_click.ogg")) - } - if (_shootMP != null) { - _shootMP!!.start() + if (mediaActionSound == null) { + mediaActionSound = MediaActionSound() + // Optional: Preload the sound for faster playback the first time. + // This is useful if you call shootSound frequently. + // mediaActionSound?.load(MediaActionSound.SHUTTER_CLICK) } + mediaActionSound?.play(MediaActionSound.SHUTTER_CLICK) } } diff --git a/app/src/main/java/com/todobom/opennotescanner/OpenNoteScannerApplication.kt b/app/src/main/java/com/todobom/opennotescanner/OpenNoteScannerApplication.kt index d8ef2b8..e354f66 100644 --- a/app/src/main/java/com/todobom/opennotescanner/OpenNoteScannerApplication.kt +++ b/app/src/main/java/com/todobom/opennotescanner/OpenNoteScannerApplication.kt @@ -49,7 +49,9 @@ class OpenNoteScannerApplication : MatomoApplication() { mSharedPref.registerOnSharedPreferenceChangeListener(mPreferenceChangeListener) // When working on an app we don't want to skew tracking results. - tracker.dryRunTarget = if (BuildConfig.DEBUG) Collections.synchronizedList(ArrayList()) else null + if (BuildConfig.DEBUG) { + tracker.dryRunTarget = Collections.synchronizedList(ArrayList()) + } // If you want to set a specific userID other than the random UUID token, do it NOW to ensure all future actions use that token. // Changing it later will track new events as belonging to a different user. diff --git a/app/src/main/java/com/todobom/opennotescanner/SettingsFragment.kt b/app/src/main/java/com/todobom/opennotescanner/SettingsFragment.kt index 2e0292a..30a58a0 100644 --- a/app/src/main/java/com/todobom/opennotescanner/SettingsFragment.kt +++ b/app/src/main/java/com/todobom/opennotescanner/SettingsFragment.kt @@ -34,7 +34,7 @@ class SettingsFragment : PreferenceFragment(), OnSharedPreferenceChangeListener } } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) { updatePreference(findPreference(key), key) } diff --git a/app/src/main/java/com/todobom/opennotescanner/helpers/AboutFragment.kt b/app/src/main/java/com/todobom/opennotescanner/helpers/AboutFragment.kt index 8d36940..bf20394 100644 --- a/app/src/main/java/com/todobom/opennotescanner/helpers/AboutFragment.kt +++ b/app/src/main/java/com/todobom/opennotescanner/helpers/AboutFragment.kt @@ -8,7 +8,8 @@ import androidx.fragment.app.DialogFragment import com.todobom.opennotescanner.OpenNoteScannerApplication import com.todobom.opennotescanner.R import org.matomo.sdk.extra.TrackHelper -import us.feras.mdv.MarkdownView +import io.noties.markwon.Markwon +import android.widget.TextView class AboutFragment : DialogFragment() { override fun onCreateView( @@ -27,8 +28,17 @@ class AboutFragment : DialogFragment() { val activity = activity ?: return val window = dialog?.window ?: return - val markdownView = view.findViewById(R.id.about_markdown) - markdownView.loadMarkdownFile("file:///android_asset/" + getString(R.string.about_filename)) + val markdownView = view.findViewById(R.id.about_markdown) + val markwon = Markwon.create(activity) + + // Load markdown content from assets + try { + val inputStream = activity.assets.open(getString(R.string.about_filename)) + val markdownContent = inputStream.bufferedReader().use { it.readText() } + markwon.setMarkdown(markdownView, markdownContent) + } catch (e: Exception) { + markdownView.text = "Error loading about content" + } val size = Point() activity.windowManager.defaultDisplay.getRealSize(size) window.setLayout((size.x * 0.9).toInt(), (size.y * 0.9).toInt()) diff --git a/app/src/main/java/com/todobom/opennotescanner/helpers/AnimationRunnable.kt b/app/src/main/java/com/todobom/opennotescanner/helpers/AnimationRunnable.kt index f51f4e0..0c1174b 100644 --- a/app/src/main/java/com/todobom/opennotescanner/helpers/AnimationRunnable.kt +++ b/app/src/main/java/com/todobom/opennotescanner/helpers/AnimationRunnable.kt @@ -1,6 +1,7 @@ package com.todobom.opennotescanner.helpers import android.graphics.Bitmap +import android.net.Uri import android.util.Log import android.view.View import android.view.animation.* @@ -10,12 +11,12 @@ import com.todobom.opennotescanner.OpenNoteScannerActivity import com.todobom.opennotescanner.R import org.opencv.core.Point import org.opencv.core.Size +import kotlin.coroutines.coroutineContext -class AnimationRunnable(private val activity: OpenNoteScannerActivity, filename: String, document: ScannedDocument) : Runnable { +class AnimationRunnable(private val activity: OpenNoteScannerActivity, private val imageUri: Uri, document: ScannedDocument) : Runnable { private val imageSize: Size = document.processed!!.size() private val previewPoints: Array? = if(document.quadrilateral != null) document.previewPoints else null private val previewSize: Size? = if(document.quadrilateral != null) document.previewSize else null - private val fileName: String = filename private var bitmap: Bitmap? = null override fun run() { @@ -62,7 +63,7 @@ class AnimationRunnable(private val activity: OpenNoteScannerActivity, filename: params.height = height / 2 } - bitmap = Utils.decodeSampledBitmapFromUri(fileName, params.width, params.height) + bitmap = Utils.decodeSampledBitmapFromUri(activity, imageUri, params.width, params.height) imageView.setImageBitmap(bitmap) imageView.visibility = View.VISIBLE val translateAnimation = TranslateAnimation( diff --git a/app/src/main/java/com/todobom/opennotescanner/helpers/CustomOpenCVLoader.kt b/app/src/main/java/com/todobom/opennotescanner/helpers/CustomOpenCVLoader.kt deleted file mode 100644 index 800ea63..0000000 --- a/app/src/main/java/com/todobom/opennotescanner/helpers/CustomOpenCVLoader.kt +++ /dev/null @@ -1,202 +0,0 @@ -package com.todobom.opennotescanner.helpers - -import android.app.AlertDialog -import android.app.Dialog -import android.app.DownloadManager -import android.content.* -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.os.IBinder -import android.provider.Settings -import android.provider.Settings.SettingNotFoundException -import android.util.Log -import android.widget.Toast -import androidx.core.content.FileProvider -import com.todobom.opennotescanner.R -import org.opencv.android.LoaderCallbackInterface -import org.opencv.android.OpenCVLoader -import java.io.File - -/** - * Created by allgood on 22/02/16. - */ -object CustomOpenCVLoader { - private val dummyServiceConnection: ServiceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName, service: IBinder) {} - override fun onServiceDisconnected(name: ComponentName) {} - } - private var myDownloadReference: Long = 0 - private var Callback: LoaderCallbackInterface? = null - private var Version: String? = null - private var mAskInstallDialog: AlertDialog? = null - fun isGooglePlayInstalled(context: Context): Boolean { - val pm = context.packageManager - - // DISABLED installation from Google Play since OpenCV Manager is removed from there - /* - try - { - PackageInfo info = pm.getPackageInfo("com.android.vending", PackageManager.GET_ACTIVITIES); - String label = (String) info.applicationInfo.loadLabel(pm); - app_installed = (label != null && label.equals("Google Play Store")); - } - catch (PackageManager.NameNotFoundException e) - { - app_installed = false; - } - */return false - } - - fun isOpenCVInstalled(Version: String?, AppContext: Context): Boolean { - val intent = Intent("org.opencv.engine.BIND") - intent.setPackage("org.opencv.engine") - val result = AppContext.bindService(intent, dummyServiceConnection, Context.BIND_AUTO_CREATE) - AppContext.unbindService(dummyServiceConnection) - return result - } - - var onComplete: MyBroadcastReceiver? = null - var waitOpenCVDialog: Dialog? = null - fun initAsync(version: String?, AppContext: Context, callback: LoaderCallbackInterface?): Boolean { - Version = version - Callback = callback - - // if dialog is showing, remove - mAskInstallDialog?.dismiss() - mAskInstallDialog = null - - // if don't have google play, check for OpenCV before trying to init - if (!isOpenCVInstalled(Version, AppContext)) { - var isNonPlayAppAllowed = false - try { - isNonPlayAppAllowed = Settings.Secure.getInt(AppContext.contentResolver, Settings.Secure.INSTALL_NON_MARKET_APPS) == 1 - } catch (e: SettingNotFoundException) { - e.printStackTrace() - } - val askInstallOpenCV = AlertDialog - .Builder(AppContext) - .setTitle(R.string.install_opencv) - .setMessage(R.string.ask_install_opencv) - .setCancelable(false) - - if (isNonPlayAppAllowed) { - askInstallOpenCV.setNeutralButton(R.string.githubdownload, object : DialogInterface.OnClickListener { - var arch = Build.SUPPORTED_ABIS[0] - override fun onClick(dialog: DialogInterface, which: Int) { - val sAndroidUrl = "https://github.com/ctodobom/opencv/releases/download/3.1.0/OpenCV_3.1.0_Manager_3.10_$arch.apk" - onComplete = MyBroadcastReceiver(AppContext) - AppContext.registerReceiver(onComplete, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) - val dm = AppContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - val request = DownloadManager.Request(Uri.parse(sAndroidUrl)) - val sDest = "file://" + Environment.getExternalStorageDirectory().toString() + "/Download/OpenCV_3.1.0_Manager_3.10_" + arch + ".apk" - request.setDestinationUri(Uri.parse(sDest)) - myDownloadReference = dm.enqueue(request) - dialog.dismiss() - waitOpenCVDialog = AlertDialog - .Builder(AppContext) - .setTitle(R.string.downloading) - .setMessage(R.string.downloading_opencv) - .setCancelable(false) - .setOnCancelListener { dialog1: DialogInterface -> - dm.remove(myDownloadReference) - AppContext.unregisterReceiver(onComplete) - dialog1.dismiss() - mAskInstallDialog = null - } - .setNegativeButton(R.string.answer_cancel) { dialog12: DialogInterface, which1: Int -> - dm.remove(myDownloadReference) - AppContext.unregisterReceiver(onComplete) - dialog12.dismiss() - mAskInstallDialog = null - } - .create() - .also { - it.show() - } - } - }) - } - if (isGooglePlayInstalled(AppContext)) { - askInstallOpenCV.setPositiveButton(R.string.googleplay) { dialog: DialogInterface, which: Int -> - dialog.dismiss() - AppContext.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=org.opencv.engine"))) - } - } - if (!isNonPlayAppAllowed && !isGooglePlayInstalled(AppContext)) { - askInstallOpenCV.setMessage(""" - ${AppContext.getString(R.string.ask_install_opencv)} - - ${AppContext.getString(R.string.messageactivateunknown)} - """.trimIndent() - ) - askInstallOpenCV.setNeutralButton(R.string.activateunknown) { dialog: DialogInterface, which: Int -> - dialog.dismiss() - AppContext.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) - } - } - mAskInstallDialog = askInstallOpenCV.create().also { - it.show() - } - } else { - // initialize opencv - return OpenCVLoader.initAsync(Version, AppContext, Callback) - } - return false - } - - class MyBroadcastReceiver(private val AppContext: Context) : BroadcastReceiver() { - override fun onReceive(ctxt: Context, intent: Intent) { - val id = intent.extras!!.getLong(DownloadManager.EXTRA_DOWNLOAD_ID) - if (id == myDownloadReference) { - val dm = AppContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - val query = DownloadManager.Query() - query.setFilterById(id) - val cursor = dm.query(query) - if (cursor.moveToFirst()) { - // get the status of the download - val columnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) - when (val status = cursor.getInt(columnIndex)) { - DownloadManager.STATUS_SUCCESSFUL -> { - val downloadFileLocalUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)) - val apkFile: File - apkFile = File(Uri.parse(downloadFileLocalUri).path) - waitOpenCVDialog?.dismiss() - AppContext.unregisterReceiver(onComplete) - val uri: Uri - val installIntent: Intent - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE) - uri = FileProvider.getUriForFile(ctxt, ctxt.applicationContext.packageName + ".fileprovider", apkFile) - } else { - installIntent = Intent(Intent.ACTION_VIEW) - uri = Uri.fromFile(apkFile) - } - installIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - installIntent.setDataAndType(uri, "application/vnd.android.package-archive") - AppContext.startActivity(installIntent) - } - DownloadManager.STATUS_FAILED -> { - // get the reason - more detail on the status - val columnReason = cursor.getColumnIndex(DownloadManager.COLUMN_REASON) - val reason = cursor.getInt(columnReason) - Toast.makeText(AppContext, - "FAILED: $reason", - Toast.LENGTH_LONG).show() - AppContext.unregisterReceiver(onComplete) - } - else -> Log.d("CustomOpenCVLoader", "Received download manager status: $status") - } - } else { - Log.d("CustomOpenCVLoader", "missing download") - AppContext.unregisterReceiver(onComplete) - } - cursor.close() - } - } - - companion object { - private const val TAG = "CustomOpenCVLoader" - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/todobom/opennotescanner/helpers/PdfHelper.kt b/app/src/main/java/com/todobom/opennotescanner/helpers/PdfHelper.kt index 098ab12..39255fd 100644 --- a/app/src/main/java/com/todobom/opennotescanner/helpers/PdfHelper.kt +++ b/app/src/main/java/com/todobom/opennotescanner/helpers/PdfHelper.kt @@ -1,10 +1,15 @@ package com.todobom.opennotescanner.helpers +import android.content.ContentValues import android.content.Context +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build import android.os.Environment import android.preference.PreferenceManager +import android.provider.MediaStore +import android.util.Log import android.widget.Toast -import com.itextpdf.io.image.ImageData import com.itextpdf.io.image.ImageDataFactory import com.itextpdf.kernel.geom.PageSize import com.itextpdf.kernel.pdf.PdfDocument @@ -13,55 +18,130 @@ import com.itextpdf.layout.Document import com.itextpdf.layout.element.Image import com.todobom.opennotescanner.R import java.io.File -import java.io.FileNotFoundException -import java.net.MalformedURLException +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream import java.text.SimpleDateFormat import java.util.* object PdfHelper { + private const val TAG = "OpenNoteScanner-Pdf" + @JvmStatic - fun mergeImagesToPdf(applicationContext: Context, files: ArrayList): String? { - //TODO move this to background thread - if (files.isEmpty()) { - Toast - .makeText(applicationContext, applicationContext.getString(R.string.no_files_selected), Toast.LENGTH_SHORT) - .show() + fun mergeImagesToPdf(applicationContext: Context, imageUris: ArrayList): Uri? { + if (imageUris.isEmpty()) { + Toast.makeText( + applicationContext, + applicationContext.getString(R.string.no_files_selected), + Toast.LENGTH_SHORT + ).show() return null } - val outputFile = ("PDF-" - + SimpleDateFormat("yyyyMMdd-HHmmss").format(Date()) + ".pdf") - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) - val pdfFilePath = File( - Environment.getExternalStorageDirectory() - .toString() + File.separator + sharedPreferences.getString("storage_folder", "OpenNoteScanner"), outputFile) - .absolutePath - var pdfWriter: PdfWriter? = null + + val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) + val customFolderName = preferences.getString("storage_folder", "OpenNoteScanner") ?: "OpenNoteScanner" + + val resolver = applicationContext.contentResolver + + val displayName = "PDF-${SimpleDateFormat("yyyyMMdd-HHmmss", Locale.getDefault()).format(Date())}.pdf" + var outputStream: OutputStream? = null + var pdfUri: Uri? = null + var preQFile: File? = null + try { - pdfWriter = PdfWriter(pdfFilePath) - } catch (e: FileNotFoundException) { - e.printStackTrace() - } - if (pdfWriter == null) { - return null - } - val pdfDocument = PdfDocument(pdfWriter) - val document = Document(pdfDocument) - files.sort() - for (file in files) { - var imageData: ImageData? = null - try { - imageData = ImageDataFactory.create(file) - } catch (e: MalformedURLException) { - e.printStackTrace() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf") + put(MediaStore.MediaColumns.IS_PENDING, 1) // Mark as pending until written + if (customFolderName.isNotBlank()) { + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOCUMENTS + File.separator + "OpenNoteScanner") + } else { + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOCUMENTS) + } + } + + pdfUri = resolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), contentValues) + pdfUri?.let { + outputStream = resolver.openOutputStream(it) + } + } else { + // Fallback for pre-Android Q + val storageDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) + val targetDir = if (customFolderName.isNotBlank()) File(storageDir, customFolderName) else storageDir + + if (!targetDir.exists()) { + if (!targetDir.mkdirs()) { + Log.e(TAG, "Failed to create directory: ${targetDir.absolutePath}") + Toast.makeText(applicationContext, "Could not create directory", Toast.LENGTH_LONG).show() + return null + } + } + + preQFile = File(targetDir, displayName) + pdfUri = Uri.fromFile(preQFile) + outputStream = FileOutputStream(preQFile) } - if (imageData == null) { + + if (outputStream == null) { + Toast.makeText(applicationContext, "Failed to create PDF output stream", Toast.LENGTH_LONG).show() return null } - val image = Image(imageData) - pdfDocument.addNewPage(PageSize(image.imageWidth, image.imageHeight)) - document.add(image) + + val pdfWriter = PdfWriter(outputStream) + val pdfDocument = PdfDocument(pdfWriter) + val document = Document(pdfDocument) + + // sorts by date + imageUris.sort() + + for (imageUri in imageUris) { + try { + applicationContext.contentResolver.openInputStream(imageUri)?.use { inputStream -> + val imageData = ImageDataFactory.create(inputStream.readBytes()) + val image = Image(imageData) + + pdfDocument.addNewPage(PageSize(image.imageWidth, image.imageHeight)) + document.add(image) + } ?: throw IOException("Failed to open stream for URI: $imageUri") + } catch (e: Exception) { // Catch more general exceptions during image processing + e.printStackTrace() + // TODO: show error + // Decide if you want to skip this image or abort the PDF creation + } + } + document.close() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (pdfUri != null) { + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + } + resolver.update(pdfUri, contentValues, null, null) + } + } else { + // for good measures + MediaScannerConnection.scanFile(applicationContext, arrayOf(preQFile?.absolutePath), arrayOf("application/pdf"), null) + } + + Toast.makeText(applicationContext, "PDF saved to ${pdfUri?.path ?: displayName}", Toast.LENGTH_LONG).show() + return pdfUri + + } catch (e: Exception) { + e.printStackTrace() + Toast.makeText(applicationContext, "Error writing PDF: ${e.message}", Toast.LENGTH_LONG).show() + // Clean up if a MediaStore entry was created but writing failed + pdfUri?.let { uri -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + try { + applicationContext.contentResolver.delete(uri, null, null) + } catch (deleteEx: Exception) { + deleteEx.printStackTrace() + } + } + } } - document.close() - return pdfFilePath + + return null } } \ No newline at end of file diff --git a/app/src/main/java/com/todobom/opennotescanner/helpers/Utils.kt b/app/src/main/java/com/todobom/opennotescanner/helpers/Utils.kt index 430b486..584605c 100644 --- a/app/src/main/java/com/todobom/opennotescanner/helpers/Utils.kt +++ b/app/src/main/java/com/todobom/opennotescanner/helpers/Utils.kt @@ -1,18 +1,22 @@ package com.todobom.opennotescanner.helpers -import android.content.ContentValues +import android.content.ContentUris import android.content.Context import android.content.SharedPreferences import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Point +import android.net.Uri +import android.os.Build import android.os.Environment import android.preference.PreferenceManager import android.provider.MediaStore +import android.util.Log import android.view.WindowManager import com.todobom.opennotescanner.OpenNoteScannerActivity import java.io.File +import java.io.InputStream import java.util.* import java.util.regex.Pattern import javax.microedition.khronos.egl.EGL10 @@ -24,74 +28,64 @@ class Utils( ) { private val mSharedPref: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(_context) - /* - * Reading file paths from SDCard - */ - val filePaths: ArrayList + val fileUris: ArrayList get() { - val filePaths = ArrayList() - val directory = File( - Environment.getExternalStorageDirectory() - .toString() + File.separator + mSharedPref.getString("storage_folder", "OpenNoteScanner")) - - // check for directory - if (directory.isDirectory) { - // getting list of file paths - val listFiles = directory.listFiles() - Arrays.sort(listFiles) { f1, f2 -> f2.name.compareTo(f1.name) } - - // Check for count - if (listFiles.size > 0) { - - // loop through all files - for (i in listFiles.indices) { - - // get file path - val filePath = listFiles[i].absolutePath - - // check for supported file extension - if (isSupportedFile(filePath)) { - // Add image path to array list - filePaths.add(filePath) - } - } - } + val imageUris = ArrayList() + val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI } - return filePaths - } - /* - * Check supported file extensions - * - * @returns boolean - */ - private fun isSupportedFile(filePath: String): Boolean { - val ext = filePath.substring(filePath.lastIndexOf(".") + 1, - filePath.length) - return AppConstant.FILE_EXTN.contains(ext.toLowerCase(Locale.getDefault())) - }// Older device - - /* - * getting screen width - */ - val screenWidth: Int - get() { - val columnWidth: Int - val wm = _context - .getSystemService(Context.WINDOW_SERVICE) as WindowManager - val display = wm.defaultDisplay - val point = Point() - try { - display.getSize(point) - } catch (ignore: NoSuchMethodError) { // Older device - point.x = display.width - point.y = display.height + val projection = arrayOf( + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.DATA // For pre-Q compatibility and direct paths, FIXME: switch to only URI + ) + + val appFolderName = mSharedPref.getString("storage_folder", "OpenNoteScanner") + val selection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "${MediaStore.Images.Media.RELATIVE_PATH} LIKE ?" + } else { + "${MediaStore.Images.Media.DATA} LIKE ?" + } + val selectionArgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // post-Q, partial path matching + arrayOf("%${Environment.DIRECTORY_PICTURES}/$appFolderName/%") + } else { + // pre-Q, direct path matching + val legacyPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).absolutePath + + File.separator + appFolderName + arrayOf("$legacyPath/%") + } + + // Sort order + val sortOrder = "${MediaStore.Images.Media.DATE_MODIFIED} DESC" // Or DATE_ADDED, DISPLAY_NAME ASC + + _context.contentResolver.query( + collection, + projection, + selection, + selectionArgs, + sortOrder + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + val contentUri: Uri = ContentUris.withAppendedId( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + id + ) + imageUris.add(contentUri) + } } - columnWidth = point.x - return columnWidth + return imageUris } companion object { + + private const val TAG = "OpenNoteScanner-Utils" + @JvmStatic val maxTextureSize: Int get() { @@ -133,7 +127,7 @@ class Utils( } @JvmStatic - fun isMatch(s: String?, pattern: String?): Boolean { + fun isMatch(s: String, pattern: String): Boolean { return try { val patt = Pattern.compile(pattern) val matcher = patt.matcher(s) @@ -143,20 +137,34 @@ class Utils( } } - fun decodeSampledBitmapFromUri(path: String?, reqWidth: Int, reqHeight: Int): Bitmap? { - var bm: Bitmap? = null - // First decode with inJustDecodeBounds=true to check dimensions - val options = BitmapFactory.Options() - options.inJustDecodeBounds = true - BitmapFactory.decodeFile(path, options) + fun decodeSampledBitmapFromUri(context: Context, uri: Uri, reqWidth: Int, reqHeight: Int): Bitmap? { + var inputStream: InputStream? = null + try { + // First decode with inJustDecodeBounds=true to check dimensions + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + inputStream = context.contentResolver.openInputStream(uri) + if (inputStream == null) { + Log.e(TAG, "decodeSampledBitmapFromUri: Could not open InputStream for URI: $uri") + return null + } + BitmapFactory.decodeStream(inputStream, null, options) + inputStream.close() - // Calculate inSampleSize - options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight) + // calculate downsample factor + options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight) - // Decode bitmap with inSampleSize set - options.inJustDecodeBounds = false - bm = BitmapFactory.decodeFile(path, options) - return bm + options.inJustDecodeBounds = false + inputStream = context.contentResolver.openInputStream(uri) + if (inputStream == null) { + Log.e(TAG, "decodeSampledBitmapFromUri: Could not reopen InputStream for URI: $uri") + return null + } + val bitmap = BitmapFactory.decodeStream(inputStream, null, options) + return bitmap + } finally { + inputStream?.close() + } } fun calculateInSampleSize( @@ -176,35 +184,29 @@ class Utils( return inSampleSize } - fun addImageToGallery(filePath: String?, context: Context) { - val values = ContentValues() - values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) - values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") - values.put(MediaStore.MediaColumns.DATA, filePath) - context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) - } - @JvmStatic - fun removeImageFromGallery(filePath: String, context: Context) { - context.contentResolver.delete(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - MediaStore.Images.Media.DATA - + "='" - + filePath - + "'", null) + fun removeImageFromGallery(fileUri: Uri, context: Context) { + try { + val rowsDeleted = context.contentResolver.delete(fileUri, null, null) + if (rowsDeleted == 0) { + Log.w(TAG, "Nothing deleted for: $fileUri") + } + } catch (e: SecurityException) { + // we should have permissions + e.printStackTrace() + } catch (e: Exception) { + e.printStackTrace() + } } @JvmStatic fun isPackageInstalled(context: Context, packagename: String): Boolean { - val pm = context.packageManager - var app_installed = false - app_installed = try { - val info = pm.getPackageInfo(packagename, PackageManager.GET_ACTIVITIES) - val label = info.applicationInfo.loadLabel(pm) as String - label != null - } catch (e: PackageManager.NameNotFoundException) { + return try { + context.packageManager.getPackageInfo(packagename, PackageManager.GET_ACTIVITIES) + true + } catch (_: PackageManager.NameNotFoundException) { false } - return app_installed } diff --git a/app/src/main/java/com/todobom/opennotescanner/views/TagEditorFragment.kt b/app/src/main/java/com/todobom/opennotescanner/views/TagEditorFragment.kt index b4e4878..4fd2b9f 100644 --- a/app/src/main/java/com/todobom/opennotescanner/views/TagEditorFragment.kt +++ b/app/src/main/java/com/todobom/opennotescanner/views/TagEditorFragment.kt @@ -1,6 +1,7 @@ package com.todobom.opennotescanner.views import android.content.res.ColorStateList +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -8,20 +9,27 @@ import android.view.ViewGroup import android.view.Window import android.widget.Button import android.widget.ImageView +import androidx.core.net.toFile import androidx.exifinterface.media.ExifInterface import androidx.fragment.app.DialogFragment import com.todobom.opennotescanner.R +import java.io.File import java.io.IOException /** * Created by allgood on 29/05/16. */ -class TagEditorFragment : DialogFragment() { +class TagEditorFragment(val fileUri: Uri) : DialogFragment() { private var mRunOnDetach: Runnable? = null - private var filePath: String? = null var stdTagsState = BooleanArray(7) var stdTags = arrayOf("rocket", "gift", "tv", "bell", "game", "star", "magnet") var stdTagsButtons = arrayOfNulls(7) + + init { + retainInstance = true + loadTags() + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val tagEditorView = inflater.inflate(R.layout.tageditor_view, container) @@ -59,58 +67,60 @@ class TagEditorFragment : DialogFragment() { } private fun loadTags() { - var exif: ExifInterface? = null - try { - exif = ExifInterface(filePath!!) + val exif = try { + context?.contentResolver?.openInputStream(fileUri)?.use { + ExifInterface(it) + } } catch (e: IOException) { e.printStackTrace() + return } - val userComment = exif!!.getAttribute("UserComment") + if (exif == null) return + + val userComment = exif.getAttribute(ExifInterface.TAG_USER_COMMENT) for (i in 0..6) { stdTagsState[i] = userComment!!.contains("<" + stdTags[i] + ">") } } private fun saveTags() { - var exif: ExifInterface? = null - try { - exif = ExifInterface(filePath!!) - } catch (e: IOException) { - e.printStackTrace() - } - var userComment = exif!!.getAttribute("UserComment") - for (i in 0..6) { - if (stdTagsState[i] && !userComment!!.contains("<" + stdTags[i] + ">")) { - userComment += "<" + stdTags[i] + ">" - } else if (!stdTagsState[i] && userComment!!.contains("<" + stdTags[i] + ">")) { - userComment!!.replace("<" + stdTags[i] + ">".toRegex(), "") + context?.let { context -> + // save as temp file + val inputStream = context.contentResolver?.openInputStream(fileUri) + if (inputStream == null) return + val tempFile = File.createTempFile("exif-edit", null, context.cacheDir) + try { + inputStream.use { input -> tempFile.outputStream().use { input.copyTo(it) } } + + // Open temp file for exif edit + val exif = ExifInterface(tempFile.absolutePath) + var userComment = exif.getAttribute("UserComment") + for (i in 0..6) { + if (stdTagsState[i] && !userComment!!.contains("<" + stdTags[i] + ">")) { + userComment += "<" + stdTags[i] + ">" + } else if (!stdTagsState[i] && userComment!!.contains("<" + stdTags[i] + ">")) { + userComment!!.replace("<" + stdTags[i] + ">".toRegex(), "") + } + } + exif.setAttribute(ExifInterface.TAG_USER_COMMENT, userComment) + exif.saveAttributes() + + // replace original with exif edited file + context.contentResolver.openOutputStream(fileUri, "w")?.use { out -> + tempFile.inputStream().use { input -> input.copyTo(out) } + } + } finally { + tempFile.delete() } } - exif.setAttribute("UserComment", userComment) - try { - exif.saveAttributes() - } catch (e: IOException) { - e.printStackTrace() - } } override fun onDetach() { super.onDetach() - if (mRunOnDetach != null) { - mRunOnDetach!!.run() - } + mRunOnDetach?.run() } fun setRunOnDetach(runOnDetach: Runnable?) { mRunOnDetach = runOnDetach } - - fun setFilePath(filePath: String?) { - this.filePath = filePath - loadTags() - } - - init { - retainInstance = true - } } \ No newline at end of file diff --git a/app/src/main/res/layout/about_view.xml b/app/src/main/res/layout/about_view.xml index d1d6f07..d47af84 100644 --- a/app/src/main/res/layout/about_view.xml +++ b/app/src/main/res/layout/about_view.xml @@ -10,13 +10,15 @@ android:layout_height="fill_parent" android:gravity="top"> -