Merge branch 'feat/remove-external-storage-permission' of agorise/bitsy-wallet into develop

This commit is contained in:
Seven 2022-05-28 18:48:06 +00:00 committed by Gogs
commit 25eec970e9
26 changed files with 509 additions and 689 deletions

View file

@ -74,8 +74,10 @@ dependencies {
implementation project(':PDFJet') implementation project(':PDFJet')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
// AndroidX // AndroidX
implementation 'androidx.activity:activity-ktx:1.2.4'
implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation "androidx.fragment:fragment-ktx:1.3.2"
implementation "androidx.preference:preference-ktx:$preference_version" implementation "androidx.preference:preference-ktx:$preference_version"
// Google // Google
implementation 'com.google.zxing:core:3.4.0' implementation 'com.google.zxing:core:3.4.0'

View file

@ -1,45 +1,49 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="cy.agorise.bitsybitshareswallet"> package="cy.agorise.bitsybitshareswallet">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application <application
android:name=".utils.BitsyApplication" android:name=".utils.BitsyApplication"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Bitsy" android:theme="@style/Theme.Bitsy"
android:requestLegacyExternalStorage="true"
tools:ignore="GoogleAppIndexingWarning"> tools:ignore="GoogleAppIndexingWarning">
<meta-data <meta-data
android:name="com.google.android.geo.API_KEY" android:name="com.google.android.geo.API_KEY"
android:value="@string/google_maps_key"/> android:value="@string/google_maps_key" />
<!-- Avoid Crashlytics crash collection for all users/builds --> <!-- Avoid Crashlytics crash collection for all users/builds -->
<meta-data <meta-data
android:name="firebase_crashlytics_collection_enabled" android:name="firebase_crashlytics_collection_enabled"
android:value="false" /> android:value="false" />
<!-- Avoid crashes with Google maps in SDK 28 (Android 9 [Pie]) --> <!-- Avoid crashes with Google maps in SDK 28 (Android 9 [Pie]) -->
<uses-library android:name="org.apache.http.legacy" android:required="false"/> <uses-library
android:name="org.apache.http.legacy"
android:required="false" />
<activity <activity
android:name=".activities.SplashActivity" android:name=".activities.SplashActivity"
android:exported="true"
android:theme="@style/SplashTheme"> android:theme="@style/SplashTheme">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
android:exported="false"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.Bitsy" android:theme="@style/Theme.Bitsy"
android:windowSoftInputMode="adjustPan"> android:windowSoftInputMode="adjustPan">
@ -50,8 +54,8 @@
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="cy.agorise.bitsybitshareswallet.FileProvider" android:authorities="cy.agorise.bitsybitshareswallet.FileProvider"
android:grantUriPermissions="true" android:exported="false"
android:exported="false"> android:grantUriPermissions="true">
<meta-data <meta-data
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/tmp_image_path" /> android:resource="@xml/tmp_image_path" />

View file

@ -0,0 +1,86 @@
package cy.agorise.bitsybitshareswallet.domain.usecase
import android.content.Context
import android.util.Log
import androidx.core.os.ConfigurationCompat
import androidx.documentfile.provider.DocumentFile
import com.opencsv.CSVWriter
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.OutputStreamWriter
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.pow
class ExportTransactionsToCsvUseCase(private val applicationContext: Context) {
suspend operator fun invoke(
transactions: List<TransferDetail>,
folderDocumentFile: DocumentFile
) = withContext(Dispatchers.IO) { // TODO Inject Dispatcher
// Create the PDF file name
val fileName = applicationContext.resources.let {
"${it.getString(R.string.app_name)}-${it.getString(R.string.title_transactions)}.csv"
}
// Obtains the path to store the PDF
val csvDocUri = folderDocumentFile.createFile("text/csv", fileName)?.uri
val contentResolver = applicationContext.contentResolver
val csvOutputStream = contentResolver.openOutputStream(csvDocUri!!, "w")
val csvWriter = CSVWriter(OutputStreamWriter(csvOutputStream))
// Add the table header
csvWriter.writeNext(
arrayOf(
R.string.title_from, R.string.title_to, R.string.title_memo, R.string.title_date,
R.string.title_time, R.string.title_amount, R.string.title_equivalent_value
).map { columnNameId -> applicationContext.getString(columnNameId) }.toTypedArray()
)
// Configure date and time formats to reuse in all the transfers
val locale = ConfigurationCompat.getLocales(applicationContext.resources.configuration)[0]
val dateFormat = SimpleDateFormat("MM-dd-yyyy", locale)
val timeFormat = SimpleDateFormat("HH:mm:ss", locale)
// Save all the transfers information
val row = Array(7) { "" } // Array initialized with empty strings
for ((index, transferDetail) in transactions.withIndex()) {
val date = Date(transferDetail.date * 1000)
row[0] = transferDetail.from ?: "" // From
row[1] = transferDetail.to ?: "" // To
row[2] = transferDetail.memo // Memo
row[3] = dateFormat.format(date) // Date
row[4] = timeFormat.format(date) // Time
// Asset Amount
val assetPrecision = transferDetail.assetPrecision
val assetAmount = transferDetail.assetAmount / 10.0.pow(assetPrecision)
row[5] =
String.format("%.${assetPrecision}f %s", assetAmount, transferDetail.assetSymbol)
// Fiat Equivalent
row[6] = if (transferDetail.fiatAmount != null && transferDetail.fiatSymbol != null) {
val currency = Currency.getInstance(transferDetail.fiatSymbol)
val fiatAmount = transferDetail.fiatAmount / 10.0.pow(currency.defaultFractionDigits)
String.format(
"%.${currency.defaultFractionDigits}f %s",
fiatAmount,
currency.currencyCode
)
} else {
""
}
csvWriter.writeNext(row)
// TODO update progress
}
csvWriter.close()
Log.d("ExportTransactionsToCsv", "CSV generated and saved")
}
}

View file

@ -0,0 +1,166 @@
package cy.agorise.bitsybitshareswallet.domain.usecase
import android.content.Context
import android.util.Log
import androidx.core.os.ConfigurationCompat
import androidx.documentfile.provider.DocumentFile
import com.pdfjet.*
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedOutputStream
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.pow
class ExportTransactionsToPdfUseCase(private val applicationContext: Context) {
suspend operator fun invoke(
transactions: List<TransferDetail>,
folderDocumentFile: DocumentFile
) = withContext(Dispatchers.IO) { // TODO Inject Dispatcher
// Create the PDF file name
val fileName = applicationContext.resources.let {
"${it.getString(R.string.app_name)}-${it.getString(R.string.title_transactions)}.pdf"
}
// Obtains the path to store the PDF
val pdfDocUri = folderDocumentFile.createFile("application/pdf", fileName)?.uri
val contentResolver = applicationContext.contentResolver
val pdfOutputStream = contentResolver.openOutputStream(pdfDocUri!!, "w")
// Creates a new PDF object
val pdf = PDF(BufferedOutputStream(pdfOutputStream), Compliance.PDF_A_1B)
// Font used for the table headers
val f1 = Font(pdf, CoreFont.HELVETICA_BOLD).apply { size = 7f }
// Font used for the table contents
val f2 = Font(pdf, CoreFont.HELVETICA).apply { size = 7f }
// Creates a new PDF table
val table = Table()
// 2D array of cells used to populate the PDF table
val tableData = mutableListOf<List<Cell>>()
// Add column names/headers
val columnNames = intArrayOf(
R.string.title_from, R.string.title_to, R.string.title_memo, R.string.title_date,
R.string.title_time, R.string.title_amount, R.string.title_equivalent_value
)
val header = columnNames.map { columnName ->
Cell(f1, applicationContext.getString(columnName)).apply { setPadding(2f) }
}
// Add the table headers
tableData.add(header)
// Add the table contents
val locale = ConfigurationCompat.getLocales(applicationContext.resources.configuration)[0]
tableData.addAll(getData(transactions, f2, locale))
// Configure the PDF table
table.setData(tableData, Table.DATA_HAS_1_HEADER_ROWS)
table.setCellBordersWidth(0.2f)
// The A4 size has 595 points of width, with the below we are trying to assign the same
// width to all cells and also keep them centered.
for (i in 0..6) {
table.setColumnWidth(i, 65f)
}
table.wrapAroundCellText()
table.mergeOverlaidBorders()
// Populate the PDF table
while (table.hasMoreData()) {
// Configures the PDF page
val page = Page(pdf, Letter.PORTRAIT)
table.setLocation(45f, 30f)
table.drawOn(page)
}
pdf.close()
Log.d("ExportTransactionsToPdf", "PDF generated and saved")
}
private suspend fun getData(
transferDetails: List<TransferDetail>,
font: Font,
locale: Locale
): List<List<Cell>> = withContext(Dispatchers.IO) { // TODO Inject Dispatcher
val tableData = mutableListOf<List<Cell>>()
// Configure date and time formats to reuse in all the transfers
val dateFormat = SimpleDateFormat("MM-dd-yyyy", locale)
val timeFormat = SimpleDateFormat("HH:mm:ss", locale)
var date: Date
// Save all the transfers information
for ((index, transferDetail) in transferDetails.withIndex()) {
val cols = mutableListOf<String>()
date = Date(transferDetail.date * 1000)
cols.add(transferDetail.from ?: "") // From
cols.add(transferDetail.to ?: "") // To
cols.add(transferDetail.memo) // Memo
cols.add(dateFormat.format(date)) // Date
cols.add(timeFormat.format(date)) // Time
// Asset Amount
val assetPrecision = transferDetail.assetPrecision
val assetAmount = transferDetail.assetAmount / 10.0.pow(assetPrecision)
cols.add(
String.format(
"%.${assetPrecision}f %s",
assetAmount,
transferDetail.assetSymbol
)
)
// Fiat Equivalent
if (transferDetail.fiatAmount != null && transferDetail.fiatSymbol != null) {
val currency = Currency.getInstance(transferDetail.fiatSymbol)
val fiatAmount =
transferDetail.fiatAmount / 10.0.pow(currency.defaultFractionDigits)
cols.add(
String.format(
"%.${currency.defaultFractionDigits}f %s",
fiatAmount, currency.currencyCode
)
)
}
val row = cols.map { col ->
Cell(font, col).apply { setPadding(2f) }
}
tableData.add(row)
appendMissingCells(tableData, font)
// TODO update progress
}
tableData
}
private fun appendMissingCells(tableData: List<List<Cell>>, font: Font) {
val firstRow = tableData[0]
val numOfColumns = firstRow.size
for (i in tableData.indices) {
val dataRow = tableData[i] as ArrayList<Cell>
val dataRowColumns = dataRow.size
if (dataRowColumns < numOfColumns) {
for (j in 0 until numOfColumns - dataRowColumns) {
dataRow.add(Cell(font))
}
dataRow[dataRowColumns - 1].colSpan = numOfColumns - dataRowColumns + 1
}
}
}
}

View file

@ -1,8 +1,6 @@
package cy.agorise.bitsybitshareswallet.fragments package cy.agorise.bitsybitshareswallet.fragments
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.os.Bundle import android.os.Bundle
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
@ -20,7 +18,6 @@ import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail
import cy.agorise.bitsybitshareswallet.databinding.FragmentEReceiptBinding import cy.agorise.bitsybitshareswallet.databinding.FragmentEReceiptBinding
import cy.agorise.bitsybitshareswallet.utils.Constants import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.utils.Helper import cy.agorise.bitsybitshareswallet.utils.Helper
import cy.agorise.bitsybitshareswallet.utils.toast
import cy.agorise.bitsybitshareswallet.viewmodels.EReceiptViewModel import cy.agorise.bitsybitshareswallet.viewmodels.EReceiptViewModel
import java.math.RoundingMode import java.math.RoundingMode
import java.text.DecimalFormat import java.text.DecimalFormat
@ -28,15 +25,10 @@ import java.text.DecimalFormatSymbols
import java.text.NumberFormat import java.text.NumberFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.math.pow
class EReceiptFragment : Fragment() { class EReceiptFragment : Fragment() {
companion object {
private const val TAG = "EReceiptFragment"
private const val REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION = 100
}
private val args: EReceiptFragmentArgs by navArgs() private val args: EReceiptFragmentArgs by navArgs()
private val viewModel: EReceiptViewModel by viewModels() private val viewModel: EReceiptViewModel by viewModels()
@ -75,9 +67,9 @@ class EReceiptFragment : Fragment() {
val transferId = args.transferId val transferId = args.transferId
viewModel.get(userId, transferId).observe(viewLifecycleOwner, { transferDetail -> viewModel.get(userId, transferId).observe(viewLifecycleOwner) { transferDetail ->
bindTransferDetail(transferDetail) bindTransferDetail(transferDetail)
}) }
} }
private fun bindTransferDetail(transferDetail: TransferDetail) { private fun bindTransferDetail(transferDetail: TransferDetail) {
@ -95,7 +87,7 @@ class EReceiptFragment : Fragment() {
df.decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault()) df.decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault())
val amount = transferDetail.assetAmount.toDouble() / val amount = transferDetail.assetAmount.toDouble() /
Math.pow(10.toDouble(), transferDetail.assetPrecision.toDouble()) 10.toDouble().pow(transferDetail.assetPrecision.toDouble())
val assetAmount = "${df.format(amount)} ${transferDetail.getUIAssetSymbol()}" val assetAmount = "${df.format(amount)} ${transferDetail.getUIAssetSymbol()}"
binding.tvAmount.text = assetAmount binding.tvAmount.text = assetAmount
@ -104,7 +96,7 @@ class EReceiptFragment : Fragment() {
val numberFormat = NumberFormat.getNumberInstance() val numberFormat = NumberFormat.getNumberInstance()
val currency = Currency.getInstance(transferDetail.fiatSymbol) val currency = Currency.getInstance(transferDetail.fiatSymbol)
val fiatEquivalent = transferDetail.fiatAmount.toDouble() / val fiatEquivalent = transferDetail.fiatAmount.toDouble() /
Math.pow(10.0, currency.defaultFractionDigits.toDouble()) 10.0.pow(currency.defaultFractionDigits.toDouble())
val equivalentValue = "${numberFormat.format(fiatEquivalent)} ${currency.currencyCode}" val equivalentValue = "${numberFormat.format(fiatEquivalent)} ${currency.currencyCode}"
binding.tvEquivalentValue.text = equivalentValue binding.tvEquivalentValue.text = equivalentValue
@ -151,47 +143,13 @@ class EReceiptFragment : Fragment() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.menu_share) { if (item.itemId == R.id.menu_share) {
verifyStoragePermission() shareEReceiptScreenshot()
return true return true
} }
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
} }
/** Verifies if the storage permission is already granted, if that is the case then it takes the screenshot and
* shares it but if it is not then it asks the user for that permission */
private fun verifyStoragePermission() {
if (ContextCompat
.checkSelfPermission(requireActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED
) {
// Permission is not already granted
requestPermissions(
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION
)
} else {
// Permission is already granted
shareEReceiptScreenshot()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION) {
if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
shareEReceiptScreenshot()
} else {
context?.toast(getString(R.string.msg__storage_permission_necessary_share))
}
return
}
}
/** Takes a screenshot as a bitmap (hiding the tx hyperlink), saves it into a temporal cache image and then /** Takes a screenshot as a bitmap (hiding the tx hyperlink), saves it into a temporal cache image and then
* sends an intent so the user can select the desired method to share the image. */ * sends an intent so the user can select the desired method to share the image. */
private fun shareEReceiptScreenshot() { private fun shareEReceiptScreenshot() {
@ -214,4 +172,8 @@ class EReceiptFragment : Fragment() {
shareIntent.type = "*/*" shareIntent.type = "*/*"
startActivity(Intent.createChooser(shareIntent, getString(R.string.text__share_with))) startActivity(Intent.createChooser(shareIntent, getString(R.string.text__share_with)))
} }
companion object {
private const val TAG = "EReceiptFragment"
}
} }

View file

@ -1,7 +1,6 @@
package cy.agorise.bitsybitshareswallet.fragments package cy.agorise.bitsybitshareswallet.fragments
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
@ -22,7 +21,6 @@ import cy.agorise.bitsybitshareswallet.databinding.FragmentReceiveTransactionBin
import cy.agorise.bitsybitshareswallet.utils.Constants import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.utils.Helper import cy.agorise.bitsybitshareswallet.utils.Helper
import cy.agorise.bitsybitshareswallet.utils.showKeyboard import cy.agorise.bitsybitshareswallet.utils.showKeyboard
import cy.agorise.bitsybitshareswallet.utils.toast
import cy.agorise.bitsybitshareswallet.viewmodels.ReceiveTransactionViewModel import cy.agorise.bitsybitshareswallet.viewmodels.ReceiveTransactionViewModel
import cy.agorise.graphenej.* import cy.agorise.graphenej.*
import cy.agorise.graphenej.api.ConnectionStatusUpdate import cy.agorise.graphenej.api.ConnectionStatusUpdate
@ -34,23 +32,10 @@ import java.text.DecimalFormat
import java.text.DecimalFormatSymbols import java.text.DecimalFormatSymbols
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.collections.ArrayList
import kotlin.math.min import kotlin.math.min
class ReceiveTransactionFragment : ConnectedFragment() { class ReceiveTransactionFragment : ConnectedFragment() {
companion object {
private const val TAG = "ReceiveTransactionFrag"
private const val RESPONSE_LIST_ASSETS = 1
private const val REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION = 100
/** Number of assets to request from the NetworkService to show as suggestions in the AutoCompleteTextView */
private const val AUTO_SUGGEST_ASSET_LIMIT = 5
private const val OTHER_ASSET = "other_asset"
}
private val viewModel: ReceiveTransactionViewModel by viewModels() private val viewModel: ReceiveTransactionViewModel by viewModels()
private var _binding: FragmentReceiveTransactionBinding? = null private var _binding: FragmentReceiveTransactionBinding? = null
@ -120,11 +105,11 @@ class ReceiveTransactionFragment : ConnectedFragment() {
val userId = PreferenceManager.getDefaultSharedPreferences(context) val userId = PreferenceManager.getDefaultSharedPreferences(context)
.getString(Constants.KEY_CURRENT_ACCOUNT_ID, "") .getString(Constants.KEY_CURRENT_ACCOUNT_ID, "")
viewModel.getUserAccount(userId!!).observe(viewLifecycleOwner, { user -> viewModel.getUserAccount(userId!!).observe(viewLifecycleOwner) { user ->
mUserAccount = UserAccount(user.id, user.name) mUserAccount = UserAccount(user.id, user.name)
}) }
viewModel.getAllNonZero().observe(viewLifecycleOwner, { assets -> viewModel.getAllNonZero().observe(viewLifecycleOwner) { assets ->
mAssets.clear() mAssets.clear()
mAssets.addAll(assets) mAssets.addAll(assets)
@ -155,11 +140,11 @@ class ReceiveTransactionFragment : ConnectedFragment() {
break break
} }
} }
}) }
viewModel.qrCodeBitmap.observe(viewLifecycleOwner, { bitmap -> viewModel.qrCodeBitmap.observe(viewLifecycleOwner) { bitmap ->
binding.ivQR.setImageBitmap(bitmap) binding.ivQR.setImageBitmap(bitmap)
}) }
binding.spAsset.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { binding.spAsset.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: AdapterView<*>?) {} override fun onNothingSelected(parent: AdapterView<*>?) {}
@ -351,47 +336,12 @@ class ReceiveTransactionFragment : ConnectedFragment() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.menu_share) { if (item.itemId == R.id.menu_share) {
verifyStoragePermission() shareQRScreenshot()
return true return true
} }
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
} }
private fun verifyStoragePermission() {
if (ContextCompat.checkSelfPermission(
requireActivity(),
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
)
!= PackageManager.PERMISSION_GRANTED
) {
// Permission is not already granted
requestPermissions(
arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE),
REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION
)
} else {
// Permission is already granted
shareQRScreenshot()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION) {
if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
shareQRScreenshot()
} else {
context?.toast(getString(R.string.msg__storage_permission_necessary_share))
}
return
}
}
/** /**
* This function takes a screenshot as a bitmap, saves it into a temporal cache image and then * This function takes a screenshot as a bitmap, saves it into a temporal cache image and then
* sends an intent so the user can select the desired method to share the image. * sends an intent so the user can select the desired method to share the image.
@ -421,4 +371,15 @@ class ReceiveTransactionFragment : ConnectedFragment() {
shareIntent.type = "*/*" shareIntent.type = "*/*"
startActivity(Intent.createChooser(shareIntent, getString(R.string.text__share_with))) startActivity(Intent.createChooser(shareIntent, getString(R.string.text__share_with)))
} }
companion object {
private const val TAG = "ReceiveTransactionFrag"
private const val RESPONSE_LIST_ASSETS = 1
/** Number of assets to request from the NetworkService to show as suggestions in the AutoCompleteTextView */
private const val AUTO_SUGGEST_ASSET_LIMIT = 5
private const val OTHER_ASSET = "other_asset"
}
} }

View file

@ -1,7 +1,6 @@
package cy.agorise.bitsybitshareswallet.models package cy.agorise.bitsybitshareswallet.ui.transactions
import android.os.Parcelable import android.os.Parcelable
import cy.agorise.bitsybitshareswallet.fragments.TransactionsFragment
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
/** /**

View file

@ -1,4 +1,4 @@
package cy.agorise.bitsybitshareswallet.fragments package cy.agorise.bitsybitshareswallet.ui.transactions
import android.content.res.Resources import android.content.res.Resources
@ -18,7 +18,6 @@ import com.google.firebase.crashlytics.FirebaseCrashlytics
import cy.agorise.bitsybitshareswallet.adapters.BalancesDetailsAdapter import cy.agorise.bitsybitshareswallet.adapters.BalancesDetailsAdapter
import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail
import cy.agorise.bitsybitshareswallet.databinding.DialogFilterOptionsBinding import cy.agorise.bitsybitshareswallet.databinding.DialogFilterOptionsBinding
import cy.agorise.bitsybitshareswallet.models.FilterOptions
import cy.agorise.bitsybitshareswallet.utils.Constants import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.utils.Helper import cy.agorise.bitsybitshareswallet.utils.Helper
import cy.agorise.bitsybitshareswallet.viewmodels.BalanceDetailViewModel import cy.agorise.bitsybitshareswallet.viewmodels.BalanceDetailViewModel
@ -110,7 +109,7 @@ class FilterOptionsDialog : DialogFragment() {
binding.cbAsset.isChecked = mFilterOptions.assetAll binding.cbAsset.isChecked = mFilterOptions.assetAll
// Configure BalanceDetailViewModel to obtain the user's Balances // Configure BalanceDetailViewModel to obtain the user's Balances
viewModel.getAll().observe(viewLifecycleOwner, { balancesDetails -> viewModel.getAll().observe(viewLifecycleOwner) { balancesDetails ->
mBalanceDetails.clear() mBalanceDetails.clear()
mBalanceDetails.addAll(balancesDetails) mBalanceDetails.addAll(balancesDetails)
mBalanceDetails.sortWith { a, b -> a.toString().compareTo(b.toString(), true) } mBalanceDetails.sortWith { a, b -> a.toString().compareTo(b.toString(), true) }
@ -128,7 +127,7 @@ class FilterOptionsDialog : DialogFragment() {
break break
} }
} }
}) }
// Initialize Equivalent Value // Initialize Equivalent Value
binding.cbEquivalentValue.setOnCheckedChangeListener { _, isChecked -> binding.cbEquivalentValue.setOnCheckedChangeListener { _, isChecked ->

View file

@ -1,13 +1,10 @@
package cy.agorise.bitsybitshareswallet.fragments package cy.agorise.bitsybitshareswallet.ui.transactions
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Point import android.graphics.Point
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.view.* import android.view.*
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
@ -18,14 +15,11 @@ import com.afollestad.materialdialogs.list.listItemsMultiChoice
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.jakewharton.rxbinding3.appcompat.queryTextChangeEvents import com.jakewharton.rxbinding3.appcompat.queryTextChangeEvents
import cy.agorise.bitsybitshareswallet.R import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.adapters.TransfersDetailsAdapter
import cy.agorise.bitsybitshareswallet.databinding.FragmentTransactionsBinding import cy.agorise.bitsybitshareswallet.databinding.FragmentTransactionsBinding
import cy.agorise.bitsybitshareswallet.models.FilterOptions import cy.agorise.bitsybitshareswallet.utils.BounceTouchListener
import cy.agorise.bitsybitshareswallet.utils.* import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.viewmodels.TransactionsViewModel
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.CompositeDisposable
import java.io.File
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -35,17 +29,14 @@ import java.util.concurrent.TimeUnit
*/ */
class TransactionsFragment : Fragment(), FilterOptionsDialog.OnFilterOptionsSelectedListener { class TransactionsFragment : Fragment(), FilterOptionsDialog.OnFilterOptionsSelectedListener {
companion object {
private const val TAG = "TransactionsFragment"
private const val REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION = 100
}
private val viewModel: TransactionsViewModel by viewModels() private val viewModel: TransactionsViewModel by viewModels()
private var _binding: FragmentTransactionsBinding? = null private var _binding: FragmentTransactionsBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private var isPdfRequested: Boolean = false
private var isCsvRequested: Boolean = false
private var mDisposables = CompositeDisposable() private var mDisposables = CompositeDisposable()
override fun onCreateView( override fun onCreateView(
@ -78,7 +69,7 @@ class TransactionsFragment : Fragment(), FilterOptionsDialog.OnFilterOptionsSele
binding.rvTransactions.layoutManager = LinearLayoutManager(context) binding.rvTransactions.layoutManager = LinearLayoutManager(context)
// Configure TransactionsViewModel to fetch the transaction history // Configure TransactionsViewModel to fetch the transaction history
viewModel.getFilteredTransactions(userId).observe(viewLifecycleOwner, { transactions -> viewModel.getFilteredTransactions(userId).observe(viewLifecycleOwner) { transactions ->
if (transactions.isEmpty()) { if (transactions.isEmpty()) {
binding.rvTransactions.visibility = View.GONE binding.rvTransactions.visibility = View.GONE
binding.tvEmpty.visibility = View.VISIBLE binding.tvEmpty.visibility = View.VISIBLE
@ -94,7 +85,7 @@ class TransactionsFragment : Fragment(), FilterOptionsDialog.OnFilterOptionsSele
if (shouldScrollUp) if (shouldScrollUp)
binding.rvTransactions.scrollToPosition(0) binding.rvTransactions.scrollToPosition(0)
} }
}) }
// Set custom touch listener to handle bounce/stretch effect // Set custom touch listener to handle bounce/stretch effect
val bounceTouchListener = BounceTouchListener(binding.rvTransactions) val bounceTouchListener = BounceTouchListener(binding.rvTransactions)
@ -136,7 +127,7 @@ class TransactionsFragment : Fragment(), FilterOptionsDialog.OnFilterOptionsSele
true true
} }
R.id.menu_export -> { R.id.menu_export -> {
verifyStoragePermission() showExportOptionsDialog()
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
@ -162,75 +153,28 @@ class TransactionsFragment : Fragment(), FilterOptionsDialog.OnFilterOptionsSele
viewModel.applyFilterOptions(filterOptions) viewModel.applyFilterOptions(filterOptions)
} }
/** Verifies that the storage permission has been granted before attempting to generate the export options */
private fun verifyStoragePermission() {
if (ContextCompat.checkSelfPermission(
requireActivity(),
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
!= PackageManager.PERMISSION_GRANTED
) {
// Permission is not already granted
requestPermissions(
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION
)
} else {
// Permission is already granted
showExportOptionsDialog()
}
}
/** Received the result of the storage permission request and if it was accepted then shows the export options
* dialog, but if it was not accepted then shows a toast explaining that the permission is necessary to generate
* the export options */
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION) {
if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
showExportOptionsDialog()
} else {
context?.toast(getString(R.string.msg__storage_permission_necessary_export))
}
return
}
}
private fun showExportOptionsDialog() { private fun showExportOptionsDialog() {
// Make export options selected by default
val indices = intArrayOf(0, 1)
MaterialDialog(requireContext()).show { MaterialDialog(requireContext()).show {
title(R.string.title_export_transactions) title(R.string.title_export_transactions)
listItemsMultiChoice( listItemsMultiChoice(
R.array.export_options, R.array.export_options,
initialSelection = intArrayOf(0, 1) initialSelection = indices,
waitForPositiveButton = true
) { _, indices, _ -> ) { _, indices, _ ->
val exportPDF = indices.contains(0) // Update export options selected
val exportCSV = indices.contains(1) isPdfRequested = indices.contains(0)
exportFilteredTransactions(exportPDF, exportCSV) isCsvRequested = indices.contains(1)
} }
positiveButton(R.string.title_export) negativeButton(android.R.string.cancel) { dismiss() }
positiveButton(R.string.title_export) { getFolderForExport.launch(null) }
} }
} }
/** Creates the export procedures for PDF and CSV, depending on the user selection. */ private val getFolderForExport = registerForActivityResult(OpenDocumentTree()) { folderUri ->
private fun exportFilteredTransactions(exportPDF: Boolean, exportCSV: Boolean) { viewModel.exportFilteredTransactions(folderUri, isPdfRequested, isCsvRequested)
// Verifies the BiTSy folder exists in the external storage and if it doesn't then it tries to create it
val dir = File(Environment.getExternalStorageDirectory(), Constants.EXTERNAL_STORAGE_FOLDER)
if (!dir.exists()) {
if (!dir.mkdirs())
return
}
viewModel.getFilteredTransactionsOnce()?.let { filteredTransactions ->
if (exportPDF)
activity?.let { PDFGeneratorTask(it).execute(filteredTransactions) }
if (exportCSV)
activity?.let { CSVGenerationTask(it).execute(filteredTransactions) }
}
} }
override fun onDestroy() { override fun onDestroy() {
@ -238,4 +182,8 @@ class TransactionsFragment : Fragment(), FilterOptionsDialog.OnFilterOptionsSele
if (!mDisposables.isDisposed) mDisposables.dispose() if (!mDisposables.isDisposed) mDisposables.dispose()
} }
companion object {
private const val TAG = "TransactionsFragment"
}
} }

View file

@ -0,0 +1,173 @@
package cy.agorise.bitsybitshareswallet.ui.transactions
import android.app.Application
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.*
import com.google.android.material.datepicker.MaterialDatePicker
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail
import cy.agorise.bitsybitshareswallet.domain.usecase.ExportTransactionsToCsvUseCase
import cy.agorise.bitsybitshareswallet.domain.usecase.ExportTransactionsToPdfUseCase
import cy.agorise.bitsybitshareswallet.repositories.TransferDetailRepository
import cy.agorise.bitsybitshareswallet.utils.Helper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.*
class TransactionsViewModel(application: Application) : AndroidViewModel(application) {
// TODO Inject below dependencies
private val exportTransactionsToPdfUseCase = ExportTransactionsToPdfUseCase(application)
private val exportTransactionsToCsvUseCase = ExportTransactionsToCsvUseCase(application)
private val mRepository = TransferDetailRepository(application)
/**
* [FilterOptions] used to filter the list of [TransferDetail] taken from the database
*/
private var mFilterOptions = FilterOptions()
private lateinit var transactions: LiveData<List<TransferDetail>>
/**
* This [MediatorLiveData] is used to combine two sources of information into one, keeping the
* client of this [ViewModel] receiving only one stream of data (a list of filtered [TransferDetail])
*/
private val filteredTransactions = MediatorLiveData<List<TransferDetail>>()
init {
// Initialize the start and end dates for the FilterOptions
val calendar = getClearedUtc()
calendar.timeInMillis = MaterialDatePicker.todayInUtcMilliseconds()
mFilterOptions.endDate = calendar.timeInMillis
calendar.roll(Calendar.MONTH, -2)
mFilterOptions.startDate = calendar.timeInMillis
}
private fun getClearedUtc(): Calendar {
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
utc.clear()
return utc
}
internal fun getFilteredTransactions(userId: String): LiveData<List<TransferDetail>> {
val currencyCode = Helper.getCoingeckoSupportedCurrency(Locale.getDefault())
transactions = mRepository.getAll(userId, currencyCode)
filteredTransactions.addSource(transactions) { transactions ->
viewModelScope.launch {
filteredTransactions.value = filter(transactions, mFilterOptions)
}
}
return filteredTransactions
}
internal fun getFilterOptions(): FilterOptions {
return mFilterOptions
}
internal fun applyFilterOptions(filterOptions: FilterOptions) =
transactions.value?.let { transactions ->
viewModelScope.launch {
filteredTransactions.value = filter(transactions, filterOptions)
}
}.also { mFilterOptions = filterOptions }
internal fun setFilterQuery(query: String) = transactions.value?.let { transactions ->
mFilterOptions.query = query
viewModelScope.launch {
filteredTransactions.value = filter(transactions, mFilterOptions)
}
}
/**
* Filters the given list of [TransferDetail] given the [FilterOptions] and returns a filtered list
* of [TransferDetail], doing all the work in a background thread using kotlin coroutines
*/
private suspend fun filter(
transactions: List<TransferDetail>,
filterOptions: FilterOptions
): List<TransferDetail> = withContext(Dispatchers.Default) {
// Create a list to store the filtered transactions
val filteredTransactions = ArrayList<TransferDetail>()
// Make sure the filter dates use the same format as the transactions' dates
val startDate = filterOptions.startDate / 1000
val endDate = filterOptions.endDate / 1000
for (transaction in transactions) {
// Filter by transfer direction
if (transaction.direction) { // Transfer sent
if (filterOptions.transactionsDirection == 1)
// Looking for received transfers only
continue
} else { // Transfer received
if (filterOptions.transactionsDirection == 2)
// Looking for sent transactions only
continue
}
// Filter by date range
if (!filterOptions.dateRangeAll && (transaction.date < startDate ||
transaction.date > endDate)
)
continue
// Filter by asset
if (!filterOptions.assetAll && transaction.assetSymbol != filterOptions.asset)
continue
// Filter by equivalent value
if (!filterOptions.equivalentValueAll && ((transaction.fiatAmount
?: -1) < filterOptions.fromEquivalentValue
|| (transaction.fiatAmount ?: -1) > filterOptions.toEquivalentValue)
)
continue
// Filter transactions sent to agorise
if (filterOptions.agoriseFees && transaction.to.equals("agorise"))
continue
// Filter by search query
val text = "${transaction.from ?: ""} ${transaction.to ?: ""} ${transaction.memo}"
if (text.contains(filterOptions.query, ignoreCase = true)) {
filteredTransactions.add(transaction)
}
}
filteredTransactions
}
/** Creates the export procedures for PDF and CSV, depending on the user selection. */
fun exportFilteredTransactions(folderUri: Uri, exportPdf: Boolean, exportCsv: Boolean) {
Log.d(TAG, "Export options selected. PDF: $exportPdf, CSV: $exportCsv")
if (!exportPdf && !exportCsv) return
// TODO Use injected context
val folderDocumentFile = DocumentFile.fromTreeUri(getApplication(), folderUri) ?: return
val transactions = filteredTransactions.value ?: return
if (exportPdf) {
viewModelScope.launch {
// TODO Show success/failure message
exportTransactionsToPdfUseCase(transactions, folderDocumentFile)
}
}
if (exportCsv) {
viewModelScope.launch {
// TODO Show success/failure message
exportTransactionsToCsvUseCase(transactions, folderDocumentFile)
}
}
}
companion object {
private const val TAG = "TransactionsViewModel"
}
}

View file

@ -1,4 +1,4 @@
package cy.agorise.bitsybitshareswallet.adapters package cy.agorise.bitsybitshareswallet.ui.transactions
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
@ -16,7 +16,6 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SortedList import androidx.recyclerview.widget.SortedList
import cy.agorise.bitsybitshareswallet.R import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail
import cy.agorise.bitsybitshareswallet.fragments.TransactionsFragmentDirections
import java.math.RoundingMode import java.math.RoundingMode
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.DecimalFormatSymbols import java.text.DecimalFormatSymbols
@ -93,7 +92,7 @@ class TransfersDetailsAdapter(private val context: Context) :
val tvFiatEquivalent: TextView = itemView.findViewById(R.id.tvFiatEquivalent) val tvFiatEquivalent: TextView = itemView.findViewById(R.id.tvFiatEquivalent)
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TransfersDetailsAdapter.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(context) val inflater = LayoutInflater.from(context)
val transactionView = inflater.inflate(R.layout.item_transaction, parent, false) val transactionView = inflater.inflate(R.layout.item_transaction, parent, false)

View file

@ -1,102 +0,0 @@
package cy.agorise.bitsybitshareswallet.utils
import android.content.Context
import android.os.AsyncTask
import android.os.Environment
import android.util.Log
import com.opencsv.CSVWriter
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail
import java.io.File
import java.io.FileWriter
import java.lang.ref.WeakReference
import java.text.SimpleDateFormat
import java.util.*
/**
* AsyncTask subclass used to move the CSV generation procedure to a background thread
* and inform the UI of the progress.
*/
class CSVGenerationTask(context: Context) : AsyncTask<List<TransferDetail>, Int, String>() {
companion object {
private const val TAG = "CSVGenerationTask"
}
private val mContext: WeakReference<Context> = WeakReference(context)
override fun doInBackground(vararg params: List<TransferDetail>): String {
return createCSVDocument(params[0])
}
private fun createCSVDocument(transferDetails: List<TransferDetail>): String {
return try {
// Create and configure a new CSV file to save the transfers list
val externalStorageFolder = Environment.getExternalStorageDirectory().absolutePath + File.separator +
Constants.EXTERNAL_STORAGE_FOLDER
val fileName = mContext.get()?.resources?.let {
"${it.getString(R.string.app_name)}-${it.getString(R.string.title_transactions)}"} + ".csv"
val file = File(externalStorageFolder, fileName)
file.createNewFile()
val csvWriter = CSVWriter(FileWriter(file))
// Add the table header
val row = Array(7) {""} // Array initialized with empty strings
val columnNames = arrayOf(R.string.title_from, R.string.title_to, R.string.title_memo, R.string.title_date,
R.string.title_time, R.string.title_amount, R.string.title_equivalent_value)
for ((i, columnName) in columnNames.withIndex()) {
row[i] = mContext.get()?.getString(columnName) ?: ""
}
csvWriter.writeNext(row)
// Configure date and time formats to reuse in all the transfers
val locale = mContext.get()?.resources?.configuration?.locale
val dateFormat = SimpleDateFormat("MM-dd-yyyy", locale)
val timeFormat = SimpleDateFormat("HH:mm:ss", locale)
// Save all the transfers information
for ( (index, transferDetail) in transferDetails.withIndex()) {
val date = Date(transferDetail.date * 1000)
row[0] = transferDetail.from ?: "" // From
row[1] = transferDetail.to ?: "" // To
row[2] = transferDetail.memo // Memo
row[3] = dateFormat.format(date) // Date
row[4] = timeFormat.format(date) // Time
// Asset Amount
val assetPrecision = transferDetail.assetPrecision
val assetAmount = transferDetail.assetAmount.toDouble() / Math.pow(10.0, assetPrecision.toDouble())
row[5] = String.format("%.${assetPrecision}f %s", assetAmount, transferDetail.assetSymbol)
// Fiat Equivalent
row[6] = if (transferDetail.fiatAmount != null && transferDetail.fiatSymbol != null) {
val currency = Currency.getInstance(transferDetail.fiatSymbol)
val fiatAmount = transferDetail.fiatAmount.toDouble() /
Math.pow(10.0, currency.defaultFractionDigits.toDouble())
String.format("%.${currency.defaultFractionDigits}f %s", fiatAmount, currency.currencyCode)
} else {
""
}
csvWriter.writeNext(row)
// TODO update progress
}
csvWriter.close()
"CSV generated and saved: ${file.absolutePath}"
} catch (e: Exception) {
Log.e(TAG, "Exception while trying to generate a CSV. Msg: " + e.message)
"Unable to generate CSV. Please retry. Error: ${e.message}"
}
}
override fun onProgressUpdate(values: Array<Int>) {
// TODO show progress
}
override fun onPostExecute(message: String) {
mContext.get()?.toast(message)
}
}

View file

@ -48,9 +48,6 @@ object Constants {
/** Coingecko's API URL */ /** Coingecko's API URL */
const val COINGECKO_URL = "https://api.coingecko.com" const val COINGECKO_URL = "https://api.coingecko.com"
/** Key used to store and retrieve a cache of the coingecko supported currencies */
const val KEY_COINGECKO_CURRENCIES_CACHE = "key_coingecko_currencies_cache"
/** The fee to send in every transfer (0.01%) */ /** The fee to send in every transfer (0.01%) */
const val FEE_PERCENTAGE = 0.0001 const val FEE_PERCENTAGE = 0.0001
@ -122,9 +119,6 @@ object Constants {
/** Constant used to decide whether or not to update the tellers and merchants info from the webservice */ /** Constant used to decide whether or not to update the tellers and merchants info from the webservice */
const val MERCHANTS_UPDATE_PERIOD = 1000L * 60 * 60 + 3 // 3 hours const val MERCHANTS_UPDATE_PERIOD = 1000L * 60 * 60 + 3 // 3 hours
/** Name of the external storage folder used to save files like PDF and CSV exports and Backups **/
const val EXTERNAL_STORAGE_FOLDER = "BiTSy"
/** Constant used to check if the current connected node is out of sync */ /** Constant used to check if the current connected node is out of sync */
const val CHECK_NODE_OUT_OF_SYNC = 10 // 10 seconds const val CHECK_NODE_OUT_OF_SYNC = 10 // 10 seconds

View file

@ -1,215 +0,0 @@
package cy.agorise.bitsybitshareswallet.utils
import android.content.Context
import android.os.AsyncTask
import android.os.Environment
import android.util.Log
import androidx.core.os.ConfigurationCompat
import com.pdfjet.*
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail
import java.io.*
import java.lang.Exception
import java.lang.ref.WeakReference
import java.text.SimpleDateFormat
import java.util.*
/**
* AsyncTask subclass used to move the PDF generation procedure to a background thread
* and inform the UI of the progress.
*/
class PDFGeneratorTask(context: Context) : AsyncTask<List<TransferDetail>, Int, String>() {
companion object {
private const val TAG = "PDFGeneratorTask"
}
private val mContextRef: WeakReference<Context> = WeakReference(context)
override fun doInBackground(vararg params: List<TransferDetail>): String {
return createPDFDocument(params[0])
}
private fun combinePath(path1: String, path2: String): String {
val file1 = File(path1)
val file2 = File(file1, path2)
return file2.path
}
/** Creates an empty file with the given name, in case it does not exist */
private fun createEmptyFile(path: String) {
try {
val file = File(path)
val writer = FileWriter(file)
writer.flush()
writer.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
private fun createPDFDocument(transferDetails: List<TransferDetail>): String {
return try {
// Create the PDF file name
val fileName = mContextRef.get()?.resources?.let {
"${it.getString(R.string.app_name)}-${it.getString(R.string.title_transactions)}"} + ".pdf"
// Obtains the path to store the PDF
val externalStorageFolder = Environment.getExternalStorageDirectory().absolutePath + File.separator +
Constants.EXTERNAL_STORAGE_FOLDER
val filePath = combinePath(externalStorageFolder, fileName)
createEmptyFile(filePath)
// Creates a new PDF object
val pdf = PDF(
BufferedOutputStream(
FileOutputStream(filePath)), Compliance.PDF_A_1B)
// Font used for the table headers
val f1 = Font(pdf, CoreFont.HELVETICA_BOLD)
f1.size = 7f
// Font used for the table contents
val f2 = Font(pdf, CoreFont.HELVETICA)
f2.size = 7f
// Creates a new PDF table
val table = Table()
// 2D array of cells used to populate the PDF table
val tableData = ArrayList<List<Cell>>()
// Add column names/headers
val columnNames = intArrayOf(R.string.title_from, R.string.title_to, R.string.title_memo, R.string.title_date,
R.string.title_time, R.string.title_amount, R.string.title_equivalent_value)
val header = ArrayList<Cell>()
for (columnName in columnNames) {
val cell = Cell(f1, mContextRef.get()?.getString(columnName))
cell.setTopPadding(2f)
cell.setBottomPadding(2f)
cell.setLeftPadding(2f)
cell.setRightPadding(2f)
header.add(cell)
}
// Add the table headers
tableData.add(header)
// Add the table contents
mContextRef.get()?.let { context ->
val locale = ConfigurationCompat.getLocales(context.resources.configuration)[0]
tableData.addAll(getData(transferDetails, f2, locale))
}
// Configure the PDF table
table.setData(tableData, Table.DATA_HAS_1_HEADER_ROWS)
table.setCellBordersWidth(0.2f)
// The A4 size has 595 points of width, with the below we are trying to assign the same
// width to all cells and also keep them centered.
for (i in 0..6) {
table.setColumnWidth(i, 65f)
}
table.wrapAroundCellText()
table.mergeOverlaidBorders()
// Populate the PDF table
while (table.hasMoreData()) {
// Configures the PDF page
val page = Page(pdf, Letter.PORTRAIT)
table.setLocation(45f, 30f)
table.drawOn(page)
}
pdf.close()
"PDF generated and saved: $filePath"
} catch (e: Exception) {
Log.e(TAG, "Exception while trying to generate a PDF. Msg: " + e.message)
"Unable to generate PDF. Please retry. Error: ${e.message}"
}
}
private fun getData(transferDetails: List<TransferDetail>, font: Font, locale: Locale): List<List<Cell>> {
val tableData = ArrayList<List<Cell>>()
// Configure date and time formats to reuse in all the transfers
val dateFormat = SimpleDateFormat("MM-dd-yyyy", locale)
val timeFormat = SimpleDateFormat("HH:mm:ss", locale)
var date: Date
// Save all the transfers information
for ( (index, transferDetail) in transferDetails.withIndex()) {
val row = ArrayList<Cell>()
val cols = ArrayList<String>()
date = Date(transferDetail.date * 1000)
cols.add(transferDetail.from ?: "") // From
cols.add(transferDetail.to ?: "") // To
cols.add(transferDetail.memo) // Memo
cols.add(dateFormat.format(date)) // Date
cols.add(timeFormat.format(date)) // Time
// Asset Amount
val assetPrecision = transferDetail.assetPrecision
val assetAmount = transferDetail.assetAmount.toDouble() / Math.pow(10.0, assetPrecision.toDouble())
cols.add(String.format("%.${assetPrecision}f %s", assetAmount, transferDetail.assetSymbol))
// Fiat Equivalent
if (transferDetail.fiatAmount != null && transferDetail.fiatSymbol != null) {
val currency = Currency.getInstance(transferDetail.fiatSymbol)
val fiatAmount = transferDetail.fiatAmount.toDouble() /
Math.pow(10.0, currency.defaultFractionDigits.toDouble())
cols.add(String.format("%.${currency.defaultFractionDigits}f %s",
fiatAmount, currency.currencyCode))
}
for (col in cols) {
val cell = Cell(font, col)
cell.setTopPadding(2f)
cell.setBottomPadding(2f)
cell.setLeftPadding(2f)
cell.setRightPadding(2f)
row.add(cell)
}
tableData.add(row)
appendMissingCells(tableData, font)
// TODO update progress
}
return tableData
}
private fun appendMissingCells(tableData: List<List<Cell>>, font: Font) {
val firstRow = tableData[0]
val numOfColumns = firstRow.size
for (i in tableData.indices) {
val dataRow = tableData[i] as ArrayList<Cell>
val dataRowColumns = dataRow.size
if (dataRowColumns < numOfColumns) {
for (j in 0 until numOfColumns - dataRowColumns) {
dataRow.add(Cell(font))
}
dataRow[dataRowColumns - 1].colSpan = numOfColumns - dataRowColumns + 1
}
}
}
override fun onProgressUpdate(values: Array<Int>) {
// TODO show progress
}
override fun onPostExecute(message: String) {
mContextRef.get()?.toast(message)
}
}

View file

@ -1,136 +0,0 @@
package cy.agorise.bitsybitshareswallet.viewmodels
import android.app.Application
import androidx.lifecycle.*
import com.google.android.material.datepicker.MaterialDatePicker
import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail
import cy.agorise.bitsybitshareswallet.models.FilterOptions
import cy.agorise.bitsybitshareswallet.repositories.TransferDetailRepository
import cy.agorise.bitsybitshareswallet.utils.Helper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.*
class TransactionsViewModel(application: Application) : AndroidViewModel(application) {
companion object {
const val TAG = "TransactionsViewModel"
}
private var mRepository = TransferDetailRepository(application)
/**
* [FilterOptions] used to filter the list of [TransferDetail] taken from the database
*/
private var mFilterOptions = FilterOptions()
private lateinit var transactions : LiveData<List<TransferDetail>>
/**
* This [MediatorLiveData] is used to combine two sources of information into one, keeping the
* client of this [ViewModel] receiving only one stream of data (a list of filtered [TransferDetail])
*/
private val filteredTransactions = MediatorLiveData<List<TransferDetail>>()
init {
// Initialize the start and end dates for the FilterOptions
val calendar = getClearedUtc()
calendar.timeInMillis = MaterialDatePicker.todayInUtcMilliseconds()
mFilterOptions.endDate = calendar.timeInMillis
calendar.roll(Calendar.MONTH, -2)
mFilterOptions.startDate = calendar.timeInMillis
}
private fun getClearedUtc(): Calendar {
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
utc.clear()
return utc
}
internal fun getFilteredTransactions(userId: String): LiveData<List<TransferDetail>> {
val currencyCode = Helper.getCoingeckoSupportedCurrency(Locale.getDefault())
transactions = mRepository.getAll(userId, currencyCode)
filteredTransactions.addSource(transactions) { transactions ->
viewModelScope.launch {
filteredTransactions.value = filter(transactions, mFilterOptions)
}
}
return filteredTransactions
}
internal fun getFilterOptions(): FilterOptions {
return mFilterOptions
}
internal fun applyFilterOptions(filterOptions: FilterOptions) = transactions.value?.let { transactions ->
viewModelScope.launch {
filteredTransactions.value = filter(transactions, filterOptions)
}
}.also { mFilterOptions = filterOptions }
internal fun setFilterQuery(query: String) = transactions.value?.let { transactions ->
mFilterOptions.query = query
viewModelScope.launch {
filteredTransactions.value = filter(transactions, mFilterOptions)
}
}
internal fun getFilteredTransactionsOnce() = filteredTransactions.value
/**
* Filters the given list of [TransferDetail] given the [FilterOptions] and returns a filtered list
* of [TransferDetail], doing all the work in a background thread using kotlin coroutines
*/
private suspend fun filter(transactions: List<TransferDetail>, filterOptions: FilterOptions) : List<TransferDetail> {
return withContext(Dispatchers.Default) {
// Create a list to store the filtered transactions
val filteredTransactions = ArrayList<TransferDetail>()
// Make sure the filter dates use the same format as the transactions' dates
val startDate = filterOptions.startDate / 1000
val endDate = filterOptions.endDate / 1000
for (transaction in transactions) {
// Filter by transfer direction
if (transaction.direction) { // Transfer sent
if (filterOptions.transactionsDirection == 1)
// Looking for received transfers only
continue
} else { // Transfer received
if (filterOptions.transactionsDirection == 2)
// Looking for sent transactions only
continue
}
// Filter by date range
if (!filterOptions.dateRangeAll && (transaction.date < startDate ||
transaction.date > endDate))
continue
// Filter by asset
if (!filterOptions.assetAll && transaction.assetSymbol != filterOptions.asset)
continue
// Filter by equivalent value
if (!filterOptions.equivalentValueAll && ((transaction.fiatAmount ?: -1 ) < filterOptions.fromEquivalentValue
|| (transaction.fiatAmount ?: -1) > filterOptions.toEquivalentValue))
continue
// Filter transactions sent to agorise
if (filterOptions.agoriseFees && transaction.to.equals("agorise"))
continue
// Filter by search query
val text = "${transaction.from ?: ""} ${transaction.to ?: ""} ${transaction.memo}"
if (text.contains(filterOptions.query, ignoreCase = true)) {
filteredTransactions.add(transaction)
}
}
filteredTransactions
}
}
}

View file

@ -50,7 +50,7 @@
<fragment <fragment
android:id="@+id/transactions_dest" android:id="@+id/transactions_dest"
android:name="cy.agorise.bitsybitshareswallet.fragments.TransactionsFragment" android:name="cy.agorise.bitsybitshareswallet.ui.transactions.TransactionsFragment"
android:label="@string/title_transactions" android:label="@string/title_transactions"
tools:layout="@layout/fragment_transactions"> tools:layout="@layout/fragment_transactions">

View file

@ -65,7 +65,6 @@
<string name="title_export_transactions">Exportieren Sie gefilterte Transaktionen</string> <string name="title_export_transactions">Exportieren Sie gefilterte Transaktionen</string>
<string name="text__pdf">PDF</string> <string name="text__pdf">PDF</string>
<string name="text__csv">CSV</string> <string name="text__csv">CSV</string>
<string name="msg__storage_permission_necessary_export">Zum Exportieren von PDF / CSV-Dateien ist eine Speichererlaubnis erforderlich.</string>
<string name="title_from">Von</string> <string name="title_from">Von</string>
<string name="title_to">Zu</string> <string name="title_to">Zu</string>
<string name="title_memo">Memo</string> <string name="title_memo">Memo</string>
@ -126,7 +125,6 @@
<string name="msg__invoice_subject">BiTSy Rechnung von %1$s</string> <string name="msg__invoice_subject">BiTSy Rechnung von %1$s</string>
<string name="title_share">Aktie</string> <string name="title_share">Aktie</string>
<string name="text__share_with">Teilen mit</string> <string name="text__share_with">Teilen mit</string>
<string name="msg__storage_permission_necessary_share">Für die Freigabe von Bildern ist eine Speichererlaubnis erforderlich.</string>
<!-- Settings --> <!-- Settings -->
<string name="title_settings">Einstellungen</string> <string name="title_settings">Einstellungen</string>

View file

@ -65,7 +65,6 @@
<string name="title_export_transactions">Exportar transacciones filtradas</string> <string name="title_export_transactions">Exportar transacciones filtradas</string>
<string name="text__pdf">PDF</string> <string name="text__pdf">PDF</string>
<string name="text__csv">CSV</string> <string name="text__csv">CSV</string>
<string name="msg__storage_permission_necessary_export">El permiso de almacenamiento es necesario para exportar los archivos PDF/CSV.</string>
<string name="title_from">De</string> <string name="title_from">De</string>
<string name="title_to">Para</string> <string name="title_to">Para</string>
<string name="title_memo">Memo</string> <string name="title_memo">Memo</string>
@ -125,7 +124,6 @@
<string name="msg__invoice_subject">Invoice BiTSy de %1$s</string> <string name="msg__invoice_subject">Invoice BiTSy de %1$s</string>
<string name="title_share">Compartir</string> <string name="title_share">Compartir</string>
<string name="text__share_with">Compartir con</string> <string name="text__share_with">Compartir con</string>
<string name="msg__storage_permission_necessary_share">El permiso de almacenamiento es necesario para compartir imágenes.</string>
<!-- Settings --> <!-- Settings -->
<string name="title_settings">Ajustes</string> <string name="title_settings">Ajustes</string>

View file

@ -65,7 +65,6 @@
<string name="title_export_transactions">Exporter les transactions filtrées</string> <string name="title_export_transactions">Exporter les transactions filtrées</string>
<string name="text__pdf">PDF</string> <string name="text__pdf">PDF</string>
<string name="text__csv">CSV</string> <string name="text__csv">CSV</string>
<string name="msg__storage_permission_necessary_export">Une autorisation de stockage est nécessaire pour exporter des fichiers PDF / CSV.</string>
<string name="title_from">De</string> <string name="title_from">De</string>
<string name="title_to">À</string> <string name="title_to">À</string>
<string name="title_memo">Note</string> <string name="title_memo">Note</string>
@ -126,7 +125,6 @@
<string name="msg__invoice_subject">Facture biTSy de %1$s</string> <string name="msg__invoice_subject">Facture biTSy de %1$s</string>
<string name="title_share">Partager</string> <string name="title_share">Partager</string>
<string name="text__share_with">Partager avec</string> <string name="text__share_with">Partager avec</string>
<string name="msg__storage_permission_necessary_share">Une autorisation de stockage est nécessaire pour partager des images.</string>
<!-- Settings --> <!-- Settings -->
<string name="title_settings">Réglages</string> <string name="title_settings">Réglages</string>

View file

@ -65,7 +65,6 @@
<string name="title_export_transactions">फ़िल्टर्ड लेनदेन को निर्यात करें</string> <string name="title_export_transactions">फ़िल्टर्ड लेनदेन को निर्यात करें</string>
<string name="text__pdf">पीडीएफ</string> <string name="text__pdf">पीडीएफ</string>
<string name="text__csv">सीएसवी</string> <string name="text__csv">सीएसवी</string>
<string name="msg__storage_permission_necessary_export">PDF / CSV फ़ाइलों को निर्यात करने के लिए भंडारण अनुमति आवश्यक है।</string>
<string name="title_from">से</string> <string name="title_from">से</string>
<string name="title_to">सेवा मेरे</string> <string name="title_to">सेवा मेरे</string>
<string name="title_memo">मेमो</string> <string name="title_memo">मेमो</string>
@ -126,7 +125,6 @@
<string name="msg__invoice_subject">%1$s से बीटीएसआई चालान</string> <string name="msg__invoice_subject">%1$s से बीटीएसआई चालान</string>
<string name="title_share">शेयर</string> <string name="title_share">शेयर</string>
<string name="text__share_with">के साथ शेयर करें</string> <string name="text__share_with">के साथ शेयर करें</string>
<string name="msg__storage_permission_necessary_share">छवियों को साझा करने के लिए भंडारण की अनुमति आवश्यक है।</string>
<!-- Settings --> <!-- Settings -->
<string name="title_settings">सेटिंग्स</string> <string name="title_settings">सेटिंग्स</string>

View file

@ -65,7 +65,6 @@
<string name="title_export_transactions">フィルタリングされたトランザクションをエクスポートする</string> <string name="title_export_transactions">フィルタリングされたトランザクションをエクスポートする</string>
<string name="text__pdf">PDF</string> <string name="text__pdf">PDF</string>
<string name="text__csv">CSV</string> <string name="text__csv">CSV</string>
<string name="msg__storage_permission_necessary_export">PDF / CSVファイルをエクスポートするには、ストレージ許可が必要です。</string>
<string name="title_from">から</string> <string name="title_from">から</string>
<string name="title_to"></string> <string name="title_to"></string>
<string name="title_memo">メモ</string> <string name="title_memo">メモ</string>
@ -126,7 +125,6 @@
<string name="msg__invoice_subject">%1$sからのBiTSy請求書</string> <string name="msg__invoice_subject">%1$sからのBiTSy請求書</string>
<string name="title_share">シェア</string> <string name="title_share">シェア</string>
<string name="text__share_with">と共有する</string> <string name="text__share_with">と共有する</string>
<string name="msg__storage_permission_necessary_share">画像を共有するには保存許可が必要です。</string>
<!-- Settings --> <!-- Settings -->
<string name="title_settings">設定</string> <string name="title_settings">設定</string>

View file

@ -65,7 +65,6 @@
<string name="title_export_transactions">ਫਿਲਟਰ ਟ੍ਰਾਂਜੈਕਸ਼ਨਾਂ ਨੂੰ ਐਕਸਪੋਰਟ ਕਰੋ</string> <string name="title_export_transactions">ਫਿਲਟਰ ਟ੍ਰਾਂਜੈਕਸ਼ਨਾਂ ਨੂੰ ਐਕਸਪੋਰਟ ਕਰੋ</string>
<string name="text__pdf">ਪੀਡੀਐਫ</string> <string name="text__pdf">ਪੀਡੀਐਫ</string>
<string name="text__csv">CSV</string> <string name="text__csv">CSV</string>
<string name="msg__storage_permission_necessary_export">ਸਟੋਰੇਜ਼ ਦੀ ਇਜ਼ਾਜ਼ਤ PDF / CSV ਫਾਈਲਾਂ ਨੂੰ ਨਿਰਯਾਤ ਕਰਨ ਲਈ ਜ਼ਰੂਰੀ ਹੈ.</string>
<string name="title_from">ਤੋਂ</string> <string name="title_from">ਤੋਂ</string>
<string name="title_to">ਨੂੰ</string> <string name="title_to">ਨੂੰ</string>
<string name="title_memo">ਮੀਮੋ</string> <string name="title_memo">ਮੀਮੋ</string>
@ -126,7 +125,6 @@
<string name="msg__invoice_subject">%1$s ਦਾ ਬੀ.ਟੀ.ਸੀ ਚਲਾਨ</string> <string name="msg__invoice_subject">%1$s ਦਾ ਬੀ.ਟੀ.ਸੀ ਚਲਾਨ</string>
<string name="title_share">ਸਾਂਝਾ ਕਰੋ</string> <string name="title_share">ਸਾਂਝਾ ਕਰੋ</string>
<string name="text__share_with">ਨਾਲ ਸਾਂਝਾ ਕਰੋ</string> <string name="text__share_with">ਨਾਲ ਸਾਂਝਾ ਕਰੋ</string>
<string name="msg__storage_permission_necessary_share">ਤਸਵੀਰਾਂ ਨੂੰ ਸਾਂਝਾ ਕਰਨ ਲਈ ਸਟੋਰੇਜ਼ ਦੀ ਆਗਿਆ ਜ਼ਰੂਰੀ ਹੈ.</string>
<!-- Settings --> <!-- Settings -->
<string name="title_settings">ਸੈਟਿੰਗਜ਼</string> <string name="title_settings">ਸੈਟਿੰਗਜ਼</string>

View file

@ -65,7 +65,6 @@
<string name="title_export_transactions">Exportar transações filtradas</string> <string name="title_export_transactions">Exportar transações filtradas</string>
<string name="text__pdf">PDF</string> <string name="text__pdf">PDF</string>
<string name="text__csv">CSV</string> <string name="text__csv">CSV</string>
<string name="msg__storage_permission_necessary_export">É necessária permissão de armazenamento para exportar arquivos PDF / CSV.</string>
<string name="title_from">De</string> <string name="title_from">De</string>
<string name="title_to">Para</string> <string name="title_to">Para</string>
<string name="title_memo">Memorando</string> <string name="title_memo">Memorando</string>
@ -126,7 +125,6 @@
<string name="msg__invoice_subject">Fatura BiTSy de %1$s</string> <string name="msg__invoice_subject">Fatura BiTSy de %1$s</string>
<string name="title_share">Compartilhar</string> <string name="title_share">Compartilhar</string>
<string name="text__share_with">Compartilhar com</string> <string name="text__share_with">Compartilhar com</string>
<string name="msg__storage_permission_necessary_share">É necessária permissão de armazenamento para compartilhar imagens.</string>
<!-- Settings --> <!-- Settings -->
<string name="title_settings">Definições</string> <string name="title_settings">Definições</string>

View file

@ -65,7 +65,6 @@
<string name="title_export_transactions">Экспорт отфильтрованных транзакций</string> <string name="title_export_transactions">Экспорт отфильтрованных транзакций</string>
<string name="text__pdf">PDF</string> <string name="text__pdf">PDF</string>
<string name="text__csv">CSV</string> <string name="text__csv">CSV</string>
<string name="msg__storage_permission_necessary_export">Разрешение на хранение необходимо для экспорта файлов PDF / CSV.</string>
<string name="title_from">От</string> <string name="title_from">От</string>
<string name="title_to">к</string> <string name="title_to">к</string>
<string name="title_memo">напоминание</string> <string name="title_memo">напоминание</string>
@ -126,7 +125,6 @@
<string name="msg__invoice_subject">Счет BiTSy от %1$s</string> <string name="msg__invoice_subject">Счет BiTSy от %1$s</string>
<string name="title_share">Поделиться</string> <string name="title_share">Поделиться</string>
<string name="text__share_with">Поделиться с</string> <string name="text__share_with">Поделиться с</string>
<string name="msg__storage_permission_necessary_share">Разрешение на хранение необходимо для обмена изображениями.</string>
<!-- Settings --> <!-- Settings -->
<string name="title_settings">настройки</string> <string name="title_settings">настройки</string>

View file

@ -65,7 +65,6 @@
<string name="title_export_transactions">导出已过滤的交易</string> <string name="title_export_transactions">导出已过滤的交易</string>
<string name="text__pdf">PDF</string> <string name="text__pdf">PDF</string>
<string name="text__csv">CSV</string> <string name="text__csv">CSV</string>
<string name="msg__storage_permission_necessary_export">导出PDF / CSV文件需要存储权限。</string>
<string name="title_from"></string> <string name="title_from"></string>
<string name="title_to"></string> <string name="title_to"></string>
<string name="title_memo">备忘录</string> <string name="title_memo">备忘录</string>
@ -126,7 +125,6 @@
<string name="msg__invoice_subject">来自%1$s的BiTSy发票</string> <string name="msg__invoice_subject">来自%1$s的BiTSy发票</string>
<string name="title_share">分享</string> <string name="title_share">分享</string>
<string name="text__share_with">与某人分享</string> <string name="text__share_with">与某人分享</string>
<string name="msg__storage_permission_necessary_share">共享图像需要存储权限。</string>
<!-- Settings --> <!-- Settings -->
<string name="title_settings">设置</string> <string name="title_settings">设置</string>

View file

@ -67,7 +67,6 @@
<string name="title_export_transactions">Export filtered transactions</string> <string name="title_export_transactions">Export filtered transactions</string>
<string name="text__pdf">PDF</string> <string name="text__pdf">PDF</string>
<string name="text__csv">CSV</string> <string name="text__csv">CSV</string>
<string name="msg__storage_permission_necessary_export">Storage permission is necessary to export PDF/CSV files.</string>
<string name="title_from">From</string> <string name="title_from">From</string>
<string name="title_to">To</string> <string name="title_to">To</string>
<string name="title_memo">Memo</string> <string name="title_memo">Memo</string>
@ -128,7 +127,6 @@
<string name="msg__invoice_subject">BiTSy invoice from %1$s</string> <string name="msg__invoice_subject">BiTSy invoice from %1$s</string>
<string name="title_share">Share</string> <string name="title_share">Share</string>
<string name="text__share_with">Share with</string> <string name="text__share_with">Share with</string>
<string name="msg__storage_permission_necessary_share">Storage permission is necessary to share images.</string>
<!-- Settings --> <!-- Settings -->
<string name="title_settings">Settings</string> <string name="title_settings">Settings</string>