Skip to content

Commit fba6f19

Browse files
committed
add one-click csv export and fix schema consistency issues
1 parent c1fffb3 commit fba6f19

14 files changed

+962
-209
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ captures and logs notifications from your device including app name, title, text
2222
3. install and grant notification access permission
2323
4. configure app filters and enable logging
2424

25+
**troubleshooting permissions**:
26+
if you encounter "restricted setting" error when trying to grant notification permission:
27+
1. go to your apps list in android settings
28+
2. find "notodata" app
29+
3. tap the three dots (⋮) in the top right corner
30+
4. select "allow restricted settings"
31+
5. then retry granting the notification permission
32+
2533
## setup
2634

2735
### required

app/src/main/java/com/noto/notodata/AppFilterActivity.kt

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ class AppFilterActivity : AppCompatActivity() {
138138

139139
loadInstalledApps()
140140
}
141+
142+
override fun onDestroy() {
143+
super.onDestroy()
144+
// Cleanup adapter to prevent memory leaks
145+
appFilterAdapter.cleanup()
146+
}
141147

142148
private fun setupViews() {
143149
supportActionBar?.title = "App Filter Settings"
@@ -156,6 +162,10 @@ class AppFilterActivity : AppCompatActivity() {
156162
adapter = appFilterAdapter
157163
layoutManager = LinearLayoutManager(this@AppFilterActivity)
158164

165+
// Performance optimizations
166+
setHasFixedSize(true) // Size won't change, improves performance
167+
setItemViewCacheSize(20) // Cache more views for smoother scrolling
168+
159169
// Add scroll listener for pagination
160170
addOnScrollListener(object : RecyclerView.OnScrollListener() {
161171
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
@@ -234,16 +244,19 @@ class AppFilterActivity : AppCompatActivity() {
234244
progressBarLoading.visibility = View.GONE
235245
}
236246

237-
Toast.makeText(
238-
this@AppFilterActivity,
239-
if (currentBatch == 0) {
240-
"Loaded ${batchResult.filters.size} apps (${batchResult.userCount} user, ${batchResult.systemCount} system)" +
241-
if (shouldContinueAutoLoading) " - Loading more..." else ""
242-
} else {
243-
"Loaded ${batchResult.filters.size} more apps"
244-
},
245-
Toast.LENGTH_SHORT
246-
).show()
247+
// Only show toast for meaningful batch loads (avoid "loaded 0 apps" spam)
248+
if (batchResult.filters.isNotEmpty() || currentBatch == 0) {
249+
Toast.makeText(
250+
this@AppFilterActivity,
251+
if (currentBatch == 0) {
252+
"Loaded ${batchResult.filters.size} apps (${batchResult.userCount} user, ${batchResult.systemCount} system)" +
253+
if (shouldContinueAutoLoading) " - Loading more..." else ""
254+
} else {
255+
"Loaded ${batchResult.filters.size} more apps"
256+
},
257+
Toast.LENGTH_SHORT
258+
).show()
259+
}
247260

248261
// Automatically load next batch if we haven't reached initial load size
249262
if (shouldContinueAutoLoading) {
@@ -264,7 +277,7 @@ class AppFilterActivity : AppCompatActivity() {
264277
}
265278

266279
private suspend fun discoverAllApps(): List<ApplicationInfo> = withContext(Dispatchers.IO) {
267-
Log.d(TAG, "=== STARTING APP DISCOVERY ===")
280+
Log.d(TAG, "=== STARTING OPTIMIZED APP DISCOVERY ===")
268281

269282
val pm = packageManager
270283

@@ -275,8 +288,19 @@ class AppFilterActivity : AppCompatActivity() {
275288
val allPackages = pm.getInstalledPackages(PACKAGE_FLAGS)
276289
Log.d(TAG, "Total packages found: ${allPackages.size}")
277290

291+
// Pre-build launcher activity cache for better performance
292+
val launcherActivityCache = buildLauncherActivityCache(pm)
293+
Log.d(TAG, "Built launcher activity cache with ${launcherActivityCache.size} entries")
294+
278295
// Choose the best app source and sort by priority (user apps first)
279296
val finalAppsToProcess = chooseBestAppSource(allApps, allPackages)
297+
.filter { appInfo ->
298+
// Pre-filter to reduce processing load
299+
val packageName = appInfo.packageName
300+
packageName != this@AppFilterActivity.packageName &&
301+
packageName.isNotBlank() &&
302+
!isLowLevelSystemApp(packageName)
303+
}
280304
.sortedWith(compareBy<ApplicationInfo> {
281305
// User apps first (lower priority number = higher priority)
282306
val isSystemApp = (it.flags and ApplicationInfo.FLAG_SYSTEM) != 0
@@ -296,6 +320,36 @@ class AppFilterActivity : AppCompatActivity() {
296320
finalAppsToProcess
297321
}
298322

323+
/**
324+
* Build launcher activity cache in batch for better performance
325+
*/
326+
private suspend fun buildLauncherActivityCache(pm: PackageManager): Map<String, Boolean> = withContext(Dispatchers.IO) {
327+
val cache = mutableMapOf<String, Boolean>()
328+
329+
try {
330+
// Get all launcher activities in one query (much faster than individual checks)
331+
val launcherIntent = Intent(Intent.ACTION_MAIN).apply {
332+
addCategory(Intent.CATEGORY_LAUNCHER)
333+
}
334+
335+
val launcherActivities = pm.queryIntentActivities(launcherIntent, PackageManager.MATCH_DEFAULT_ONLY)
336+
val launcherPackages = launcherActivities.map { it.activityInfo.packageName }.toSet()
337+
338+
Log.d(TAG, "Found ${launcherPackages.size} packages with launcher activities")
339+
340+
// Get all installed packages and mark which ones have launcher activities
341+
val allPackages = pm.getInstalledApplications(APP_FLAGS)
342+
for (appInfo in allPackages) {
343+
cache[appInfo.packageName] = launcherPackages.contains(appInfo.packageName)
344+
}
345+
346+
} catch (e: Exception) {
347+
Log.e(TAG, "Error building launcher activity cache", e)
348+
}
349+
350+
cache
351+
}
352+
299353
private suspend fun processBatchOfApps(): AppLoadResult = withContext(Dispatchers.IO) {
300354
val batchSize = APPS_PER_BATCH
301355
val startIndex = currentBatch * batchSize

app/src/main/java/com/noto/notodata/MainActivity.kt

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import android.content.Context
66
import android.content.Intent
77
import android.content.IntentFilter
88
import android.content.SharedPreferences
9+
import android.content.pm.PackageManager
10+
import android.os.Build
911
import android.os.Bundle
12+
import android.os.Environment
1013
import android.provider.Settings
1114
import android.text.TextUtils
1215
import android.widget.Button
@@ -15,9 +18,16 @@ import android.widget.TextView
1518
import android.widget.Toast
1619
import android.widget.ProgressBar
1720
import androidx.appcompat.app.AppCompatActivity
21+
import androidx.core.app.ActivityCompat
22+
import androidx.core.content.ContextCompat
23+
import androidx.core.content.FileProvider
1824
import androidx.recyclerview.widget.LinearLayoutManager
1925
import androidx.recyclerview.widget.RecyclerView
2026
import androidx.lifecycle.lifecycleScope
27+
import java.io.File
28+
import java.io.FileWriter
29+
import java.text.SimpleDateFormat
30+
import java.util.Locale
2131
import kotlinx.coroutines.Dispatchers
2232
import kotlinx.coroutines.launch
2333
import kotlinx.coroutines.withContext
@@ -51,6 +61,7 @@ class MainActivity : AppCompatActivity() {
5161
private lateinit var btnRequestPermission: Button
5262
private lateinit var btnAppFilters: Button
5363
private lateinit var btnViewLocalStorage: Button
64+
private lateinit var btnExportCsv: Button
5465
private lateinit var btnClearDatabase: Button
5566
private lateinit var btnSettings: Button
5667
private lateinit var recyclerView: RecyclerView
@@ -121,6 +132,7 @@ class MainActivity : AppCompatActivity() {
121132
btnRequestPermission = findViewById(R.id.btnRequestPermission)
122133
btnAppFilters = findViewById(R.id.btnAppFilters)
123134
btnViewLocalStorage = findViewById(R.id.btnViewLocalStorage)
135+
btnExportCsv = findViewById(R.id.btnExportCsv)
124136
btnClearDatabase = findViewById(R.id.btnClearDatabase)
125137
btnSettings = findViewById(R.id.btnSettings)
126138
recyclerView = findViewById(R.id.recyclerViewNotifications)
@@ -140,6 +152,7 @@ class MainActivity : AppCompatActivity() {
140152
btnSyncNow.setOnClickListener { syncData() }
141153
btnAppFilters.setOnClickListener { openAppFilterSettings() }
142154
btnViewLocalStorage.setOnClickListener { viewLocalStorage() }
155+
btnExportCsv.setOnClickListener { exportCsvDirectly() }
143156
btnClearDatabase.setOnClickListener { clearDatabase() }
144157
btnSettings.setOnClickListener { openSettings() }
145158

@@ -666,5 +679,141 @@ class MainActivity : AppCompatActivity() {
666679
btnClearDatabase.isEnabled = enabled
667680
btnAppFilters.isEnabled = enabled
668681
btnViewLocalStorage.isEnabled = enabled
682+
btnExportCsv.isEnabled = enabled
683+
}
684+
685+
/**
686+
* Direct CSV export from main screen - single-click solution
687+
*/
688+
private fun exportCsvDirectly() {
689+
// Check permissions for older Android versions
690+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
691+
if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
692+
!= PackageManager.PERMISSION_GRANTED) {
693+
ActivityCompat.requestPermissions(
694+
this,
695+
arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE),
696+
1001
697+
)
698+
return
699+
}
700+
}
701+
702+
showLoading(true)
703+
setButtonsEnabled(false)
704+
btnExportCsv.text = "📁 Exporting..."
705+
706+
lifecycleScope.launch {
707+
try {
708+
val (fileName, fileUri) = withContext(kotlinx.coroutines.Dispatchers.IO) {
709+
val notifications = database.notificationDao().getAllNotifications()
710+
711+
if (notifications.isEmpty()) {
712+
throw Exception("No notifications to export")
713+
}
714+
715+
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(java.util.Date())
716+
val fileName = "noto_data_export_$timestamp.csv"
717+
718+
// Use app-specific external storage (no permission needed on Android 10+)
719+
val exportDir = File(getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "exports")
720+
if (!exportDir.exists()) {
721+
exportDir.mkdirs()
722+
}
723+
724+
val file = File(exportDir, fileName)
725+
726+
// Write CSV file
727+
try {
728+
FileWriter(file).use { writer ->
729+
// Write CSV header with all fields including new persistent notification fields
730+
writer.append("ID,Device ID,Dataset Name,Package Name,App Label,Title,Text,Sender,Timestamp,Category,Channel ID,Extras,Is Synced,Created At,Is Persistent,Notification Flags,Notification Key,Progress Current,Progress Max,Is Duplicate Detected\n")
731+
732+
// Write notification data with new persistent fields
733+
notifications.forEach { notification ->
734+
writer.append("${notification.id},")
735+
writer.append("\"${notification.deviceId}\",")
736+
writer.append("\"${notification.datasetName}\",")
737+
writer.append("\"${notification.packageName}\",")
738+
writer.append("\"${notification.appLabel ?: ""}\",")
739+
writer.append("\"${notification.title?.replace("\"", "\"\"") ?: ""}\",")
740+
writer.append("\"${notification.text?.replace("\"", "\"\"") ?: ""}\",")
741+
writer.append("\"${notification.sender?.replace("\"", "\"\"") ?: ""}\",")
742+
writer.append("\"${notification.timestamp}\",")
743+
writer.append("\"${notification.category ?: ""}\",")
744+
writer.append("\"${notification.channelId ?: ""}\",")
745+
writer.append("\"${notification.extras ?: ""}\",")
746+
writer.append("${notification.synced},")
747+
writer.append("\"${notification.createdAt}\",")
748+
// New persistent notification fields
749+
writer.append("${notification.isPersistent},")
750+
writer.append("${notification.notificationFlags},")
751+
writer.append("\"${notification.notificationKey ?: ""}\",")
752+
writer.append("${notification.progressCurrent},")
753+
writer.append("${notification.progressMax},")
754+
writer.append("${notification.isDuplicateDetected}\n")
755+
}
756+
}
757+
} catch (e: Exception) {
758+
throw Exception("Failed to write CSV file: ${e.message}", e)
759+
}
760+
761+
val fileUri = FileProvider.getUriForFile(
762+
this@MainActivity,
763+
"${applicationContext.packageName}.fileprovider",
764+
file
765+
)
766+
767+
Pair(fileName, fileUri)
768+
}
769+
770+
showLoading(false)
771+
setButtonsEnabled(true)
772+
btnExportCsv.text = "📁 Export CSV"
773+
774+
// Show success and offer to share
775+
androidx.appcompat.app.AlertDialog.Builder(this@MainActivity)
776+
.setTitle("Export Successful")
777+
.setMessage("Data exported to: $fileName\n\nWould you like to share the file?")
778+
.setPositiveButton("Share") { _, _ ->
779+
shareExportedFile(fileUri, fileName)
780+
}
781+
.setNegativeButton("OK", null)
782+
.show()
783+
784+
} catch (e: Exception) {
785+
android.util.Log.e("MainActivity", "Error exporting data", e)
786+
787+
showLoading(false)
788+
setButtonsEnabled(true)
789+
btnExportCsv.text = "📁 Export CSV"
790+
791+
Toast.makeText(this@MainActivity, "Export failed: ${e.message}", Toast.LENGTH_LONG).show()
792+
}
793+
}
794+
}
795+
796+
private fun shareExportedFile(fileUri: android.net.Uri, fileName: String) {
797+
try {
798+
val shareIntent = Intent().apply {
799+
action = Intent.ACTION_SEND
800+
type = "text/csv"
801+
putExtra(Intent.EXTRA_STREAM, fileUri)
802+
putExtra(Intent.EXTRA_SUBJECT, "Noto Data Export - $fileName")
803+
putExtra(Intent.EXTRA_TEXT, "Exported notification data from Noto app")
804+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
805+
}
806+
807+
val chooser = Intent.createChooser(shareIntent, "Share export file")
808+
if (chooser.resolveActivity(packageManager) != null) {
809+
startActivity(chooser)
810+
} else {
811+
Toast.makeText(this, "No apps available to share the file", Toast.LENGTH_SHORT).show()
812+
}
813+
814+
} catch (e: Exception) {
815+
android.util.Log.e("MainActivity", "Error sharing file", e)
816+
Toast.makeText(this, "Error sharing file: ${e.message}", Toast.LENGTH_SHORT).show()
817+
}
669818
}
670819
}

0 commit comments

Comments
 (0)