diff --git a/app/build.gradle b/app/build.gradle index a679c0e..6bb19f6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -45,6 +45,7 @@ dependencies { //noinspection GradleDependency: 3.12.* is the latest version compatible with Android <5 implementation 'com.squareup.okhttp3:okhttp:3.12.12' implementation 'com.gitlab.xynngh:LibPhoneNumberInfo:fd60ad9583' + implementation 'org.apache.commons:commons-csv:1.8' implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.recyclerview:recyclerview:1.1.0' diff --git a/app/src/main/java/dummydomain/yetanothercallblocker/BlacklistActivity.java b/app/src/main/java/dummydomain/yetanothercallblocker/BlacklistActivity.java index 030628e..e97aaf6 100644 --- a/app/src/main/java/dummydomain/yetanothercallblocker/BlacklistActivity.java +++ b/app/src/main/java/dummydomain/yetanothercallblocker/BlacklistActivity.java @@ -1,15 +1,19 @@ package dummydomain.yetanothercallblocker; import android.annotation.SuppressLint; +import android.app.Activity; import android.content.Context; import android.content.Intent; import android.os.AsyncTask; import android.os.Bundle; +import android.os.ParcelFileDescriptor; import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ActionMode; @@ -19,17 +23,29 @@ import androidx.recyclerview.widget.RecyclerView; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileWriter; +import java.io.IOException; import java.util.List; +import dummydomain.yetanothercallblocker.data.BlacklistImporterExporter; import dummydomain.yetanothercallblocker.data.BlacklistService; import dummydomain.yetanothercallblocker.data.YacbHolder; import dummydomain.yetanothercallblocker.data.db.BlacklistDao; import dummydomain.yetanothercallblocker.data.db.BlacklistItem; import dummydomain.yetanothercallblocker.event.BlacklistChangedEvent; +import dummydomain.yetanothercallblocker.utils.FileUtils; public class BlacklistActivity extends AppCompatActivity { + private static final int REQUEST_CODE_IMPORT = 1; + + private static final Logger LOG = LoggerFactory.getLogger(BlacklistActivity.class); + private final Settings settings = App.getSettings(); private final BlacklistDao blacklistDao = YacbHolder.getBlacklistDao(); private final BlacklistService blacklistService = YacbHolder.getBlacklistService(); @@ -172,6 +188,38 @@ public class BlacklistActivity extends AppCompatActivity { super.onDestroy(); } + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_CODE_IMPORT && resultCode == Activity.RESULT_OK + && data != null && data.getData() != null) { + boolean error = false; + + ParcelFileDescriptor pfd = null; + try { + pfd = getContentResolver().openFileDescriptor(data.getData(), "r"); + } catch (FileNotFoundException e) { + error = true; + LOG.warn("onActivityResult() get file for import result", e); + } + + if (pfd != null) { + if (new BlacklistImporterExporter().importBlacklist( + YacbHolder.getBlacklistDao(), YacbHolder.getBlacklistService(), + pfd.getFileDescriptor())) { + Toast.makeText(this, R.string.done, Toast.LENGTH_SHORT).show(); + } else { + error = true; + } + } + + if (error) { + Toast.makeText(this, R.string.error, Toast.LENGTH_SHORT).show(); + } + } + } + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) public void onBlacklistChanged(BlacklistChangedEvent blacklistChangedEvent) { loadItems(); @@ -214,4 +262,42 @@ public class BlacklistActivity extends AppCompatActivity { startActivity(EditBlacklistItemActivity.getIntent(this, blacklistItem.getId())); } + public void onExportBlacklistClicked(MenuItem item) { + File file = exportBlacklist(); + if (file != null) { + FileUtils.shareFile(this, file); + } else { + Toast.makeText(this, R.string.error, Toast.LENGTH_SHORT).show(); + } + } + + private File exportBlacklist() { + File file = new File(getCacheDir(), "YetAnotherCallBlocker_backup.csv"); + try { + if (!file.exists() && !file.createNewFile()) return null; + + try (FileWriter writer = new FileWriter(file)) { + if (new BlacklistImporterExporter().writeBackup(blacklistDao.loadAll(), writer)) { + return file; + } + } + } catch (IOException e) { + LOG.warn("exportBlacklist()", e); + } + + return null; + } + + public void onImportBlacklistClicked(MenuItem item) { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + + if (intent.resolveActivity(getPackageManager()) != null) { + startActivityForResult(intent, REQUEST_CODE_IMPORT); + } else { + Toast.makeText(this, R.string.error, Toast.LENGTH_SHORT).show(); + } + } + } diff --git a/app/src/main/java/dummydomain/yetanothercallblocker/data/BlacklistImporterExporter.java b/app/src/main/java/dummydomain/yetanothercallblocker/data/BlacklistImporterExporter.java new file mode 100644 index 0000000..a9f12fc --- /dev/null +++ b/app/src/main/java/dummydomain/yetanothercallblocker/data/BlacklistImporterExporter.java @@ -0,0 +1,322 @@ +package dummydomain.yetanothercallblocker.data; + +import android.text.TextUtils; + +import androidx.core.util.ObjectsCompat; + +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVPrinter; +import org.apache.commons.csv.CSVRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.ArrayList; +import java.util.Date; +import java.util.Iterator; +import java.util.List; + +import dummydomain.yetanothercallblocker.data.db.BlacklistDao; +import dummydomain.yetanothercallblocker.data.db.BlacklistItem; + +import static dummydomain.yetanothercallblocker.data.BlacklistUtils.cleanPattern; +import static dummydomain.yetanothercallblocker.data.BlacklistUtils.isValidPattern; +import static dummydomain.yetanothercallblocker.data.BlacklistUtils.patternFromHumanReadable; +import static dummydomain.yetanothercallblocker.data.BlacklistUtils.patternToHumanReadable; + +public class BlacklistImporterExporter { + + private static final Logger LOG = LoggerFactory.getLogger(BlacklistImporterExporter.class); + + private static final String HEADER_ID = "ID"; + private static final String HEADER_NAME = "name"; + private static final String HEADER_PATTERN = "pattern"; + + public boolean writeBackup(Iterable blacklistItems, Appendable out) { + try (CSVPrinter printer = CSVFormat.DEFAULT.print(out)) { + printer.printRecord(HEADER_ID, HEADER_NAME, HEADER_PATTERN, + "creationTimestamp", "numberOfCalls", "lastCallTimestamp"); + + for (BlacklistItem item : blacklistItems) { + printer.printRecord(item.getId(), item.getName(), + patternToHumanReadable(item.getPattern()), + item.getCreationDate().getTime(), item.getNumberOfCalls(), + item.getLastCallDate() != null ? item.getLastCallDate().getTime() : ""); + } + } catch (IOException e) { + LOG.warn("write()", e); + return false; + } + + return true; + } + + public boolean importBlacklist(BlacklistDao blacklistDao, BlacklistService blacklistService, + FileDescriptor fileDescriptor) { + List items = null; + + try (FileInputStream inputStream = new FileInputStream(fileDescriptor)) { + items = read(inputStream); + } catch (IOException e) { + LOG.warn("importBlacklist()", e); + } + + if (items == null) return false; + + for (BlacklistItem item : items) { + BlacklistItem existingItem = null; + + if (item.getId() != null) { + existingItem = blacklistDao.findById(item.getId()); + + if (existingItem != null && !ObjectsCompat + .equals(item.getPattern(), existingItem.getPattern())) { + item.setId(null); + existingItem = null; + } + } + + if (existingItem == null) { + existingItem = blacklistDao.findByPattern(item.getPattern()); + } + + if (existingItem != null) { + boolean changed = false; + if (TextUtils.isEmpty(existingItem.getName()) + && !TextUtils.isEmpty(item.getName())) { + existingItem.setName(item.getName()); + changed = true; + } + if (existingItem.getNumberOfCalls() < item.getNumberOfCalls()) { + existingItem.setNumberOfCalls(item.getNumberOfCalls()); + changed = true; + } + if (item.getLastCallDate() != null && (existingItem.getLastCallDate() == null + || existingItem.getLastCallDate().before(item.getLastCallDate()))) { + existingItem.setLastCallDate(item.getLastCallDate()); + changed = true; + } + + if (changed) { + blacklistService.save(existingItem); + } + } else { + blacklistService.insert(item); + } + } + + return true; + } + + public List read(InputStream inputStream) throws IOException { + List items = null; + + try (BufferedInputStream bis = new BufferedInputStream(inputStream)) { + bis.mark(4000); + + if (isYacbBackup(new InputStreamReader(bis))) { + bis.reset(); + LOG.info("importBlacklist() importing as YACB backup"); + items = readYacbBackup(new InputStreamReader(bis)); + } else { + LOG.debug("importBlacklist() not a YACB backup"); + bis.reset(); + if (isNoPhoneSpamBackup(new InputStreamReader(bis))) { + bis.reset(); + LOG.info("importBlacklist() trying to import as NoPhoneSpam backup"); + items = readNoPhoneSpamBackup(new InputStreamReader(bis)); + } else { + LOG.debug("importBlacklist() not a NoPhoneSpam backup"); + } + } + } + + return items; + } + + public boolean isYacbBackup(Reader in) { + try { + CSVParser parser = CSVFormat.DEFAULT.parse(in); // do NOT close + + Iterator iterator = parser.iterator(); + if (iterator.hasNext()) { + CSVRecord record = iterator.next(); + + if (record.size() < 6) return false; + + boolean foundHeader = checkYacbHeader(record); + LOG.debug("isYacbBackup() found header={}", foundHeader); + if (foundHeader) return true; + + // check that the types match + try { + Long.parseLong(record.get(0)); + + String creationTimestampString = record.get(3); + if (!TextUtils.isEmpty(creationTimestampString)) { + Long.parseLong(creationTimestampString); + } + + Integer.parseInt(record.get(4)); + + String lastCallTimestampString = record.get(5); + if (!TextUtils.isEmpty(lastCallTimestampString)) { + Long.parseLong(lastCallTimestampString); + } + } catch (Exception e) { + LOG.debug("isYacbBackup() error parsing item", e); + return false; + } + + return true; + } + } catch (IOException e) { + LOG.debug("isYacbBackup()", e); + return false; + } + + LOG.debug("isYacbBackup() empty file?"); + return true; + } + + private boolean checkYacbHeader(CSVRecord record) { + boolean foundHeader = false; + try { + foundHeader = HEADER_ID.equals(record.get(0)) + && HEADER_NAME.equals(record.get(1)) + && HEADER_PATTERN.equals(record.get(2)); + } catch (Exception e) { + LOG.warn("checkYacbHeader() error checking header", e); + } + return foundHeader; + } + + public List readYacbBackup(Reader in) { + try (CSVParser parser = CSVFormat.DEFAULT.parse(in)) { + List blacklistItems = new ArrayList<>(); + + boolean first = true; + + for (CSVRecord record : parser) { + if (first) { + first = false; + + boolean foundHeader = checkYacbHeader(record); + LOG.debug("readYacbBackup() found header={}", foundHeader); + + if (foundHeader) { + continue; + } + } + + BlacklistItem item = new BlacklistItem(); + + boolean enough = false; + + try { + int i = 0; + item.setId(Long.valueOf(record.get(i++))); + item.setName(record.get(i++)); + item.setPattern(cleanPattern(patternFromHumanReadable(record.get(i++)))); + + enough = true; + + String creationTimestampString = record.get(i++); + if (!TextUtils.isEmpty(creationTimestampString)) { + item.setCreationDate(new Date(Long.parseLong(creationTimestampString))); + } + + item.setNumberOfCalls(Integer.parseInt(record.get(i++))); + + String lastCallTimestampString = record.get(i); + if (!TextUtils.isEmpty(lastCallTimestampString)) { + item.setLastCallDate(new Date(Long.parseLong(lastCallTimestampString))); + } + } catch (Exception e) { + LOG.warn("readYacbBackup() error parsing item", e); + } + + LOG.trace("readYacbBackup() enough={}", enough); + if (enough) { + blacklistItems.add(sanitize(item)); + } + } + + return blacklistItems; + } catch (IOException e) { + LOG.warn("readYacbBackup()", e); + return null; + } + } + + public boolean isNoPhoneSpamBackup(Reader in) { + try { + BufferedReader br = new BufferedReader(in); // do NOT close + + String delimiter = ": "; + + String line = br.readLine(); + if (line != null) { + return line.contains(delimiter); + } + } catch (IOException e) { + LOG.warn("isNoPhoneSpamBackup()", e); + return false; + } + + return true; + } + + public List readNoPhoneSpamBackup(Reader in) { + try (BufferedReader br = new BufferedReader(in)) { + List blacklistItems = new ArrayList<>(); + + String delimiter = ": "; + + String line; + while ((line = br.readLine()) != null) { + int delimiterIndex = line.indexOf(delimiter); + if (delimiterIndex == -1) { + LOG.warn("readNoPhoneSpamBackup() incorrect format: no delimiter"); + continue; + } + + String pattern = line.substring(0, delimiterIndex).trim(); + String name = line.substring(delimiterIndex + delimiter.length()); + + BlacklistItem item = new BlacklistItem(name, + cleanPattern(patternFromHumanReadable(pattern))); + blacklistItems.add(sanitize(item)); + } + + return blacklistItems; + } catch (IOException e) { + LOG.warn("readNoPhoneSpamBackup()", e); + return null; + } + } + + private BlacklistItem sanitize(BlacklistItem item) { + if (item.getId() != null && item.getId() < 1) { + item.setId(null); + } + item.setInvalid(!isValidPattern(item.getPattern())); + if (item.getCreationDate() == null) { + item.setCreationDate(new Date()); + } + if (item.getNumberOfCalls() < 0) { + item.setNumberOfCalls(0); + } + + return item; + } + +} diff --git a/app/src/main/java/dummydomain/yetanothercallblocker/data/BlacklistService.java b/app/src/main/java/dummydomain/yetanothercallblocker/data/BlacklistService.java index 094cbe4..03ffb3f 100644 --- a/app/src/main/java/dummydomain/yetanothercallblocker/data/BlacklistService.java +++ b/app/src/main/java/dummydomain/yetanothercallblocker/data/BlacklistService.java @@ -37,17 +37,25 @@ public class BlacklistService { public void save(BlacklistItem blacklistItem) { boolean newItem = blacklistItem.getId() == null; - blacklistItem.setInvalid(!BlacklistUtils.isValidPattern(blacklistItem.getPattern())); + sanitize(blacklistItem); blacklistDao.save(blacklistItem); - blacklistChanged(); + blacklistChanged(!newItem); + } - postEvent(newItem ? new BlacklistChangedEvent() : new BlacklistItemChangedEvent()); + public void insert(BlacklistItem blacklistItem) { + sanitize(blacklistItem); + blacklistDao.insert(blacklistItem); + + blacklistChanged(false); } public void addCall(BlacklistItem blacklistItem, Date date) { + sanitize(blacklistItem); + blacklistItem.setLastCallDate(Objects.requireNonNull(date)); blacklistItem.setNumberOfCalls(blacklistItem.getNumberOfCalls() + 1); + blacklistDao.save(blacklistItem); postEvent(new BlacklistItemChangedEvent()); @@ -56,11 +64,19 @@ public class BlacklistService { public void delete(Iterable keys) { blacklistDao.delete(keys); - blacklistChanged(); + blacklistChanged(false); } - private void blacklistChanged() { + private void sanitize(BlacklistItem blacklistItem) { + blacklistItem.setInvalid(!BlacklistUtils.isValidPattern(blacklistItem.getPattern())); + if (blacklistItem.getCreationDate() == null) blacklistItem.setCreationDate(new Date()); + if (blacklistItem.getNumberOfCalls() < 0) blacklistItem.setNumberOfCalls(0); + } + + private void blacklistChanged(boolean itemUpdate) { callback.changed(blacklistDao.countValid() != 0); + + postEvent(itemUpdate ? new BlacklistItemChangedEvent() : new BlacklistChangedEvent()); } } diff --git a/app/src/main/java/dummydomain/yetanothercallblocker/data/db/BlacklistDao.java b/app/src/main/java/dummydomain/yetanothercallblocker/data/db/BlacklistDao.java index bc46424..8538e95 100644 --- a/app/src/main/java/dummydomain/yetanothercallblocker/data/db/BlacklistDao.java +++ b/app/src/main/java/dummydomain/yetanothercallblocker/data/db/BlacklistDao.java @@ -3,6 +3,7 @@ package dummydomain.yetanothercallblocker.data.db; import org.greenrobot.greendao.Property; import org.greenrobot.greendao.internal.SqlUtils; import org.greenrobot.greendao.query.CloseableListIterator; +import org.greenrobot.greendao.query.Query; import org.greenrobot.greendao.query.QueryBuilder; import org.greenrobot.greendao.query.WhereCondition; import org.slf4j.Logger; @@ -44,10 +45,20 @@ public class BlacklistDao { return getBlacklistItemDao().load(id); } + public BlacklistItem findByPattern(String pattern) { + return first(getBlacklistItemDao().queryBuilder() + .where(BlacklistItemDao.Properties.Pattern.eq(pattern)) + .orderAsc(BlacklistItemDao.Properties.Pattern)); + } + public void save(BlacklistItem blacklistItem) { getBlacklistItemDao().save(blacklistItem); } + public void insert(BlacklistItem blacklistItem) { + getBlacklistItemDao().insert(blacklistItem); + } + public void delete(Iterable keys) { getBlacklistItemDao().deleteByKeyInTx(keys); } @@ -58,13 +69,7 @@ public class BlacklistDao { } public BlacklistItem getFirstMatch(String number) { - try (CloseableListIterator it = getMatchesQueryBuilder(number).build() - .listIterator()) { - if (it.hasNext()) return it.next(); - } catch (IOException e) { - LOG.debug("getFirstMatch()", e); - } - return null; + return first(getMatchesQueryBuilder(number)); } private QueryBuilder getMatchesQueryBuilder(String number) { @@ -74,6 +79,19 @@ public class BlacklistDao { .orderAsc(BlacklistItemDao.Properties.CreationDate); } + private T first(QueryBuilder queryBuilder) { + return first(queryBuilder.build()); + } + + private T first(Query query) { + try (CloseableListIterator it = query.listIterator()) { + if (it.hasNext()) return it.next(); + } catch (IOException e) { + LOG.debug("first()", e); + } + return null; + } + private BlacklistItemDao getBlacklistItemDao() { return daoSessionProvider.getDaoSession().getBlacklistItemDao(); } diff --git a/app/src/main/java/dummydomain/yetanothercallblocker/utils/DebuggingUtils.java b/app/src/main/java/dummydomain/yetanothercallblocker/utils/DebuggingUtils.java index d60562c..5b47944 100644 --- a/app/src/main/java/dummydomain/yetanothercallblocker/utils/DebuggingUtils.java +++ b/app/src/main/java/dummydomain/yetanothercallblocker/utils/DebuggingUtils.java @@ -4,8 +4,6 @@ import android.content.Context; import android.os.Build; import android.util.Log; -import androidx.core.content.ContextCompat; - import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; @@ -125,23 +123,12 @@ public class DebuggingUtils { private static File getFilesDir(Context context, boolean external) { if (external) { - File[] dirs = ContextCompat.getExternalFilesDirs(context, null); - for (File dir : dirs) { - if (dir != null) return dir; - } + File f = FileUtils.getExternalFilesDir(context); + if (f != null) return f; Log.d(TAG, "getFilesDir() no external dirs available"); } -/* - not secure enough - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (!context.isDeviceProtectedStorage()) { - context = context.createDeviceProtectedStorageContext(); - } - } -*/ - return context.getCacheDir(); } diff --git a/app/src/main/java/dummydomain/yetanothercallblocker/utils/FileUtils.java b/app/src/main/java/dummydomain/yetanothercallblocker/utils/FileUtils.java index f24f4c0..c7907a0 100644 --- a/app/src/main/java/dummydomain/yetanothercallblocker/utils/FileUtils.java +++ b/app/src/main/java/dummydomain/yetanothercallblocker/utils/FileUtils.java @@ -1,10 +1,12 @@ package dummydomain.yetanothercallblocker.utils; import android.app.Activity; +import android.content.Context; import android.content.Intent; import android.net.Uri; import androidx.core.app.ShareCompat; +import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; import org.slf4j.Logger; @@ -34,4 +36,12 @@ public class FileUtils { } } + public static File getExternalFilesDir(Context context) { + File[] dirs = ContextCompat.getExternalFilesDirs(context, null); + for (File dir : dirs) { + if (dir != null) return dir; + } + return null; + } + } diff --git a/app/src/main/res/menu/activity_blacklist.xml b/app/src/main/res/menu/activity_blacklist.xml index cb6819c..80f585f 100644 --- a/app/src/main/res/menu/activity_blacklist.xml +++ b/app/src/main/res/menu/activity_blacklist.xml @@ -7,4 +7,12 @@ android:onClick="onBlockBlacklistedChanged" android:title="@string/block_blacklisted_short" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 925c4cb..9b7ce73 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -123,6 +123,8 @@ Sauvegarder les rapports et les journaux d\'incidents sur le stockage public, sinon les rapports d\'incidents sont sauvegardés dans un dossier privé de l\'application. Les rapports peuvent contenir des données sensibles (numéros de téléphone, noms de contact). D\'autres applications ayant une autorisation de stockage peuvent avoir accès à ces données dans le stockage public Sauvegarder les journaux de log en cas de crash Sauvegarder la copie du journal de log en cas de crash (en plus d\'un stacktrace de base) + Terminé + Erreur ]]> Ouvrir l\'écran de débogage diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 4b46f8c..d640393 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -78,6 +78,8 @@ Назад Да Нет + Выполнено + Ошибка Вы уверены? Это номер из адресной книги! Для получения отзывов номер передается в сторонний сервис и может попасть к третьим лицам. Вы точно хотите посмотреть отзывы на этот номер? Разрешите доступ к журналу вызовов, чтобы здесь отображались недавние вызовы @@ -133,6 +135,8 @@ Добавить Удалить Удалить выбранные элементы? + Экспортировать + Импортировать Добавить Редактировать Имя diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 603177e..5f0163b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,6 +74,8 @@ Back Yes No + Done + Error Are you sure? Loading online reviews will leak the number to a 3rd party service. Are you sure you want to do it with a number present in your Contacts? @@ -144,6 +146,8 @@ Add Delete Delete the selected items? + Export + Import Add number Edit number Name