From c2ed94c7bf3e863bc836eaaf47478b6a18e666e7 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sat, 3 Jan 2026 17:49:47 +0100 Subject: [PATCH 1/2] docs: Mark Phase 4 as completed in UI/UX Revamp Plan --- docs/UI_UX_REVAMP_PLAN.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/UI_UX_REVAMP_PLAN.md b/docs/UI_UX_REVAMP_PLAN.md index 5370f4e..eb271db 100644 --- a/docs/UI_UX_REVAMP_PLAN.md +++ b/docs/UI_UX_REVAMP_PLAN.md @@ -397,7 +397,7 @@ Implement: ## Phase 4: App Selection Screen Overhaul -**Status:** ⬜ Not Started +**Status:** ✅ Completed (January 3, 2026) **Estimated Files:** 4-6 modified files **Dependencies:** Phase 1 complete @@ -498,17 +498,17 @@ Components: ### Phase 4 Completion Checklist -- [ ] RecyclerView replaces ListView -- [ ] Adapter with DiffUtil implemented -- [ ] Search functionality in toolbar -- [ ] Filter chips (User/System/All apps) -- [ ] List item redesigned with ConstraintLayout -- [ ] MaterialSwitch and mode chips functional -- [ ] Empty state with illustration -- [ ] Loading state (shimmer or progress) -- [ ] ExtendedFAB for save action -- [ ] `gradle assembleDebug` passes -- [ ] Changes committed: `feat(ui): Phase 4 - App Selection Overhaul` +- [x] RecyclerView replaces ListView +- [x] Adapter with DiffUtil implemented +- [x] Search functionality in toolbar +- [x] Filter chips (User/System/All apps) +- [x] List item redesigned with ConstraintLayout +- [x] MaterialSwitch and mode chips functional +- [x] Empty state with illustration +- [x] Loading state (shimmer or progress) +- [x] ExtendedFAB for save action +- [x] `gradle assembleDebug` passes +- [x] Changes committed: `feat(ui): Phase 4 - App Selection Overhaul` --- From 9b76c4495885d4be94db267429bbe4055fbc6aeb Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sat, 3 Jan 2026 17:58:30 +0100 Subject: [PATCH 2/2] feat(ui): Phase 5 - Log Viewer Enhancement Implements modernized log viewer as part of UI/UX revamp. See docs/UI_UX_REVAMP_PLAN.md Phase 5 - Redesigned layout with CoordinatorLayout, MaterialToolbar, and BottomAppBar - Replaced ScrollView+TextView with RecyclerView for better performance - Added LogEntry model class with log line parsing - Added LogEntryAdapter with DiffUtil for efficient updates - Implemented syntax highlighting by log level (ERROR/WARN/INFO/DEBUG) - Added pull-to-refresh with SwipeRefreshLayout - Added search within logs via SearchView in toolbar - Added filter chips for log level filtering - Added BottomAppBar with Share, Export, Copy All actions - Added FAB for Clear Logs with MaterialAlertDialog confirmation - Added Snackbar with Undo option after clearing logs - Added loading, empty, and error states - Replaced deprecated AsyncTask with ExecutorService - Added new drawable icons (share, export, copy, scroll_bottom) - Added comprehensive string resources for log viewer --- .../com/camerainterceptor/ui/LogEntry.java | 185 +++++++ .../camerainterceptor/ui/LogEntryAdapter.java | 133 +++++ .../ui/LogViewerActivity.java | 509 ++++++++++++++++-- app/src/main/res/drawable/ic_copy.xml | 10 + app/src/main/res/drawable/ic_export.xml | 10 + .../main/res/drawable/ic_scroll_bottom.xml | 10 + app/src/main/res/drawable/ic_share.xml | 10 + .../main/res/layout/activity_log_viewer.xml | 280 ++++++++-- app/src/main/res/layout/item_log_entry.xml | 94 ++++ app/src/main/res/menu/menu_log_viewer.xml | 18 + .../main/res/menu/menu_log_viewer_bottom.xml | 23 + app/src/main/res/values/strings.xml | 34 ++ docs/UI_UX_REVAMP_PLAN.md | 24 +- 13 files changed, 1238 insertions(+), 102 deletions(-) create mode 100644 app/src/main/java/com/camerainterceptor/ui/LogEntry.java create mode 100644 app/src/main/java/com/camerainterceptor/ui/LogEntryAdapter.java create mode 100644 app/src/main/res/drawable/ic_copy.xml create mode 100644 app/src/main/res/drawable/ic_export.xml create mode 100644 app/src/main/res/drawable/ic_scroll_bottom.xml create mode 100644 app/src/main/res/drawable/ic_share.xml create mode 100644 app/src/main/res/layout/item_log_entry.xml create mode 100644 app/src/main/res/menu/menu_log_viewer.xml create mode 100644 app/src/main/res/menu/menu_log_viewer_bottom.xml diff --git a/app/src/main/java/com/camerainterceptor/ui/LogEntry.java b/app/src/main/java/com/camerainterceptor/ui/LogEntry.java new file mode 100644 index 0000000..b25565e --- /dev/null +++ b/app/src/main/java/com/camerainterceptor/ui/LogEntry.java @@ -0,0 +1,185 @@ +package com.camerainterceptor.ui; + +import java.util.Objects; + +/** + * Model class representing a single log entry. + * Immutable data class for use with RecyclerView DiffUtil. + */ +public class LogEntry { + + public enum Level { + ERROR("ERROR"), + WARN("WARN"), + INFO("INFO"), + DEBUG("DEBUG"), + VERBOSE("VERBOSE"), + UNKNOWN(""); + + private final String tag; + + Level(String tag) { + this.tag = tag; + } + + public String getTag() { + return tag; + } + + public static Level fromString(String levelStr) { + if (levelStr == null) return UNKNOWN; + String upper = levelStr.toUpperCase().trim(); + for (Level level : values()) { + if (level.tag.equals(upper) || upper.startsWith(level.tag)) { + return level; + } + } + // Also check single letters + if (upper.equals("E")) return ERROR; + if (upper.equals("W")) return WARN; + if (upper.equals("I")) return INFO; + if (upper.equals("D")) return DEBUG; + if (upper.equals("V")) return VERBOSE; + return UNKNOWN; + } + } + + private final long id; + private final String timestamp; + private final Level level; + private final String tag; + private final String message; + private final String rawLine; + + public LogEntry(long id, String timestamp, Level level, String tag, String message, String rawLine) { + this.id = id; + this.timestamp = timestamp; + this.level = level; + this.tag = tag; + this.message = message; + this.rawLine = rawLine; + } + + public long getId() { + return id; + } + + public String getTimestamp() { + return timestamp; + } + + public Level getLevel() { + return level; + } + + public String getTag() { + return tag; + } + + public String getMessage() { + return message; + } + + public String getRawLine() { + return rawLine; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LogEntry logEntry = (LogEntry) o; + return id == logEntry.id && + Objects.equals(timestamp, logEntry.timestamp) && + level == logEntry.level && + Objects.equals(tag, logEntry.tag) && + Objects.equals(message, logEntry.message); + } + + @Override + public int hashCode() { + return Objects.hash(id, timestamp, level, tag, message); + } + + /** + * Parse a log line into a LogEntry object. + * Expected format: "TIMESTAMP [LEVEL] TAG: MESSAGE" or "TIMESTAMP LEVEL: MESSAGE" + * Falls back to raw line if parsing fails. + */ + public static LogEntry parse(long id, String line) { + if (line == null || line.isEmpty()) { + return new LogEntry(id, "", Level.UNKNOWN, "", "", line != null ? line : ""); + } + + String timestamp = ""; + Level level = Level.UNKNOWN; + String tag = ""; + String message = line; + + try { + // Try to extract timestamp (format: YYYY-MM-DD HH:MM:SS or similar) + int firstSpace = line.indexOf(' '); + if (firstSpace > 0) { + // Check if starts with date-like pattern + String potentialDate = line.substring(0, Math.min(19, line.length())); + if (potentialDate.matches("^\\d{4}-\\d{2}-\\d{2}.*") || + potentialDate.matches("^\\d{2}:\\d{2}:\\d{2}.*")) { + // Find end of timestamp (usually after second space for date+time) + int secondSpace = line.indexOf(' ', firstSpace + 1); + if (secondSpace > 0 && secondSpace < 25) { + timestamp = line.substring(0, secondSpace).trim(); + message = line.substring(secondSpace + 1).trim(); + } else { + timestamp = line.substring(0, firstSpace).trim(); + message = line.substring(firstSpace + 1).trim(); + } + } + } + + // Try to extract level [LEVEL] or LEVEL: + String remaining = message; + + // Check for [LEVEL] format + if (remaining.startsWith("[")) { + int closeBracket = remaining.indexOf(']'); + if (closeBracket > 0 && closeBracket < 10) { + String levelStr = remaining.substring(1, closeBracket); + level = Level.fromString(levelStr); + remaining = remaining.substring(closeBracket + 1).trim(); + } + } else { + // Check for LEVEL: format or LEVEL/ format (logcat style) + String[] levelPrefixes = {"ERROR:", "WARN:", "INFO:", "DEBUG:", "VERBOSE:", + "E:", "W:", "I:", "D:", "V:", + "ERROR/", "WARN/", "INFO/", "DEBUG/", "VERBOSE/", + "E/", "W/", "I/", "D/", "V/"}; + for (String prefix : levelPrefixes) { + if (remaining.toUpperCase().startsWith(prefix)) { + level = Level.fromString(prefix.replace(":", "").replace("/", "")); + remaining = remaining.substring(prefix.length()).trim(); + break; + } + } + } + + // Try to extract tag (format: TAG: message) + int colonIndex = remaining.indexOf(':'); + if (colonIndex > 0 && colonIndex < 50) { + String potentialTag = remaining.substring(0, colonIndex).trim(); + // Tags usually don't contain spaces + if (!potentialTag.contains(" ")) { + tag = potentialTag; + remaining = remaining.substring(colonIndex + 1).trim(); + } + } + + message = remaining; + + } catch (Exception e) { + // If parsing fails, keep the raw line + message = line; + } + + return new LogEntry(id, timestamp, level, tag, message, line); + } +} diff --git a/app/src/main/java/com/camerainterceptor/ui/LogEntryAdapter.java b/app/src/main/java/com/camerainterceptor/ui/LogEntryAdapter.java new file mode 100644 index 0000000..b8e3b89 --- /dev/null +++ b/app/src/main/java/com/camerainterceptor/ui/LogEntryAdapter.java @@ -0,0 +1,133 @@ +package com.camerainterceptor.ui; + +import android.graphics.drawable.GradientDrawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import com.camerainterceptor.R; + +/** + * RecyclerView Adapter for log entries with syntax highlighting by log level. + * Uses ListAdapter with DiffUtil for efficient updates. + */ +public class LogEntryAdapter extends ListAdapter { + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull LogEntry oldItem, @NonNull LogEntry newItem) { + return oldItem.getId() == newItem.getId(); + } + + @Override + public boolean areContentsTheSame(@NonNull LogEntry oldItem, @NonNull LogEntry newItem) { + return oldItem.equals(newItem); + } + }; + + public LogEntryAdapter() { + super(DIFF_CALLBACK); + } + + @NonNull + @Override + public LogViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_log_entry, parent, false); + return new LogViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull LogViewHolder holder, int position) { + holder.bind(getItem(position)); + } + + static class LogViewHolder extends RecyclerView.ViewHolder { + private final View levelIndicator; + private final TextView textTimestamp; + private final TextView textLevel; + private final TextView textMessage; + private final TextView textTag; + + LogViewHolder(@NonNull View itemView) { + super(itemView); + levelIndicator = itemView.findViewById(R.id.level_indicator); + textTimestamp = itemView.findViewById(R.id.text_timestamp); + textLevel = itemView.findViewById(R.id.text_level); + textMessage = itemView.findViewById(R.id.text_message); + textTag = itemView.findViewById(R.id.text_tag); + } + + void bind(LogEntry entry) { + int levelColor = getLevelColor(entry.getLevel()); + + // Set level indicator color + levelIndicator.setBackgroundColor(levelColor); + + // Set timestamp + if (entry.getTimestamp() != null && !entry.getTimestamp().isEmpty()) { + textTimestamp.setVisibility(View.VISIBLE); + textTimestamp.setText(entry.getTimestamp()); + } else { + textTimestamp.setVisibility(View.GONE); + } + + // Set level badge + textLevel.setText(entry.getLevel().getTag()); + GradientDrawable background = (GradientDrawable) ContextCompat.getDrawable( + itemView.getContext(), R.drawable.bg_badge); + if (background != null) { + background = (GradientDrawable) background.mutate(); + background.setColor(levelColor); + textLevel.setBackground(background); + } + + // Set message + String message = entry.getMessage(); + if (message == null || message.isEmpty()) { + message = entry.getRawLine(); + } + textMessage.setText(message); + + // Set tag + if (entry.getTag() != null && !entry.getTag().isEmpty()) { + textTag.setVisibility(View.VISIBLE); + textTag.setText(entry.getTag()); + } else { + textTag.setVisibility(View.GONE); + } + } + + private int getLevelColor(LogEntry.Level level) { + int colorRes; + switch (level) { + case ERROR: + colorRes = R.color.log_error; + break; + case WARN: + colorRes = R.color.log_warn; + break; + case INFO: + colorRes = R.color.log_info; + break; + case DEBUG: + colorRes = R.color.log_debug; + break; + case VERBOSE: + colorRes = R.color.log_verbose; + break; + default: + colorRes = R.color.log_debug; + break; + } + return ContextCompat.getColor(itemView.getContext(), colorRes); + } + } +} diff --git a/app/src/main/java/com/camerainterceptor/ui/LogViewerActivity.java b/app/src/main/java/com/camerainterceptor/ui/LogViewerActivity.java index c3f866d..7ec73d1 100644 --- a/app/src/main/java/com/camerainterceptor/ui/LogViewerActivity.java +++ b/app/src/main/java/com/camerainterceptor/ui/LogViewerActivity.java @@ -1,104 +1,511 @@ package com.camerainterceptor.ui; -import android.app.Activity; -import android.os.AsyncTask; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; import android.os.Bundle; import android.os.Environment; -import android.widget.Button; -import android.widget.ScrollView; -import android.widget.TextView; -import android.widget.Toast; +import android.os.Handler; +import android.os.Looper; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.SearchView; +import androidx.core.content.FileProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.camerainterceptor.R; +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.bottomappbar.BottomAppBar; +import com.google.android.material.chip.Chip; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.snackbar.Snackbar; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; -public class LogViewerActivity extends Activity { +/** + * Activity for viewing and managing application logs. + * Features: + * - Syntax highlighting by log level + * - Pull-to-refresh + * - Search within logs + * - Filter by log level + * - Share, export, and copy functionality + * - Clear logs with undo + */ +public class LogViewerActivity extends AppCompatActivity { - private TextView logTextView; - private ScrollView scrollView; private static final String LOG_FILE_DIR = "CameraInterceptor"; private static final String LOG_FILE_NAME = "camera_interceptor_log.txt"; + // Views + private RecyclerView recyclerLogs; + private SwipeRefreshLayout swipeRefresh; + private LinearLayout layoutLoading; + private LinearLayout layoutEmpty; + private LinearLayout layoutError; + private FloatingActionButton fabClear; + private BottomAppBar bottomAppBar; + + // Filter chips + private Chip chipError; + private Chip chipWarn; + private Chip chipInfo; + private Chip chipDebug; + + // Data + private LogEntryAdapter adapter; + private List allLogEntries = new ArrayList<>(); + private List filteredLogEntries = new ArrayList<>(); + private String searchQuery = ""; + private Set activeFilters = EnumSet.allOf(LogEntry.Level.class); + + // Async + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + // For undo functionality + private String deletedLogsContent = null; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_log_viewer); - logTextView = findViewById(R.id.text_logs); - scrollView = findViewById(R.id.scroll_view); + initViews(); + setupToolbar(); + setupRecyclerView(); + setupSwipeRefresh(); + setupFilterChips(); + setupBottomBar(); + setupFab(); + + loadLogs(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + executor.shutdown(); + } - Button refreshButton = findViewById(R.id.button_refresh); - refreshButton.setOnClickListener(v -> loadLogs()); + private void initViews() { + recyclerLogs = findViewById(R.id.recycler_logs); + swipeRefresh = findViewById(R.id.swipe_refresh); + layoutLoading = findViewById(R.id.layout_loading); + layoutEmpty = findViewById(R.id.layout_empty); + layoutError = findViewById(R.id.layout_error); + fabClear = findViewById(R.id.fab_clear); + bottomAppBar = findViewById(R.id.bottom_app_bar); - Button clearButton = findViewById(R.id.button_clear); - clearButton.setOnClickListener(v -> clearLogs()); + chipError = findViewById(R.id.chip_error); + chipWarn = findViewById(R.id.chip_warn); + chipInfo = findViewById(R.id.chip_info); + chipDebug = findViewById(R.id.chip_debug); - loadLogs(); + findViewById(R.id.button_retry).setOnClickListener(v -> loadLogs()); } - private void loadLogs() { - new LoadLogsTask().execute(); + private void setupToolbar() { + MaterialToolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + toolbar.setNavigationOnClickListener(v -> onBackPressed()); } - private void clearLogs() { - try { + private void setupRecyclerView() { + adapter = new LogEntryAdapter(); + LinearLayoutManager layoutManager = new LinearLayoutManager(this); + layoutManager.setStackFromEnd(true); // Start from bottom (newest logs) + recyclerLogs.setLayoutManager(layoutManager); + recyclerLogs.setAdapter(adapter); + } + + private void setupSwipeRefresh() { + swipeRefresh.setColorSchemeResources(R.color.md_theme_light_primary); + swipeRefresh.setOnRefreshListener(this::loadLogs); + } + + private void setupFilterChips() { + // Initially all filters are active (checked) + chipError.setChecked(true); + chipWarn.setChecked(true); + chipInfo.setChecked(true); + chipDebug.setChecked(true); + + View.OnClickListener filterListener = v -> { + updateActiveFilters(); + applyFilters(); + }; + + chipError.setOnClickListener(filterListener); + chipWarn.setOnClickListener(filterListener); + chipInfo.setOnClickListener(filterListener); + chipDebug.setOnClickListener(filterListener); + } + + private void updateActiveFilters() { + activeFilters.clear(); + if (chipError.isChecked()) activeFilters.add(LogEntry.Level.ERROR); + if (chipWarn.isChecked()) activeFilters.add(LogEntry.Level.WARN); + if (chipInfo.isChecked()) activeFilters.add(LogEntry.Level.INFO); + if (chipDebug.isChecked()) { + activeFilters.add(LogEntry.Level.DEBUG); + activeFilters.add(LogEntry.Level.VERBOSE); + activeFilters.add(LogEntry.Level.UNKNOWN); + } + } + + private void setupBottomBar() { + bottomAppBar.setOnMenuItemClickListener(item -> { + int id = item.getItemId(); + if (id == R.id.action_share) { + shareLogs(); + return true; + } else if (id == R.id.action_export) { + exportLogs(); + return true; + } else if (id == R.id.action_copy) { + copyAllLogs(); + return true; + } + return false; + }); + } + + private void setupFab() { + fabClear.setOnClickListener(v -> showClearConfirmation()); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_log_viewer, menu); + + // Setup SearchView + MenuItem searchItem = menu.findItem(R.id.action_search); + SearchView searchView = (SearchView) searchItem.getActionView(); + if (searchView != null) { + searchView.setQueryHint(getString(R.string.log_search_hint)); + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + return false; + } + + @Override + public boolean onQueryTextChange(String newText) { + searchQuery = newText; + applyFilters(); + return true; + } + }); + } + + return true; + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == R.id.action_scroll_bottom) { + scrollToBottom(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void loadLogs() { + showLoading(); + + executor.execute(() -> { File logDir = new File(Environment.getExternalStorageDirectory(), LOG_FILE_DIR); File logFile = new File(logDir, LOG_FILE_NAME); - if (logFile.exists()) { - if (logFile.delete()) { - logTextView.setText("Logs cleared."); - Toast.makeText(this, "Logs cleared successfully", Toast.LENGTH_SHORT).show(); + List entries = new ArrayList<>(); + String error = null; + + if (!logFile.exists()) { + // Not an error, just empty + } else { + try (BufferedReader br = new BufferedReader(new FileReader(logFile))) { + String line; + long id = 0; + while ((line = br.readLine()) != null) { + if (!line.trim().isEmpty()) { + entries.add(LogEntry.parse(id++, line)); + } + } + } catch (IOException e) { + error = e.getMessage(); + } + } + + final List finalEntries = entries; + final String finalError = error; + + mainHandler.post(() -> { + swipeRefresh.setRefreshing(false); + + if (finalError != null) { + showError(finalError); } else { - Toast.makeText(this, "Failed to delete log file", Toast.LENGTH_SHORT).show(); + allLogEntries = finalEntries; + applyFilters(); + } + }); + }); + } + + private void applyFilters() { + filteredLogEntries.clear(); + + for (LogEntry entry : allLogEntries) { + // Check level filter + if (!activeFilters.contains(entry.getLevel())) { + continue; + } + + // Check search query + if (!searchQuery.isEmpty()) { + String lowerQuery = searchQuery.toLowerCase(); + boolean matches = entry.getRawLine().toLowerCase().contains(lowerQuery) || + (entry.getMessage() != null && entry.getMessage().toLowerCase().contains(lowerQuery)) || + (entry.getTag() != null && entry.getTag().toLowerCase().contains(lowerQuery)); + if (!matches) { + continue; } + } + + filteredLogEntries.add(entry); + } + + adapter.submitList(new ArrayList<>(filteredLogEntries)); + + if (filteredLogEntries.isEmpty()) { + if (allLogEntries.isEmpty()) { + showEmpty(); } else { - Toast.makeText(this, "No logs found", Toast.LENGTH_SHORT).show(); + // Has logs but filters hide all + showEmptyFiltered(); } - } catch (Exception e) { - Toast.makeText(this, "Error clearing logs: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } else { + showContent(); } } - private class LoadLogsTask extends AsyncTask { - @Override - protected void onPreExecute() { - logTextView.setText("Loading..."); + private void showLoading() { + layoutLoading.setVisibility(View.VISIBLE); + layoutEmpty.setVisibility(View.GONE); + layoutError.setVisibility(View.GONE); + recyclerLogs.setVisibility(View.GONE); + } + + private void showContent() { + layoutLoading.setVisibility(View.GONE); + layoutEmpty.setVisibility(View.GONE); + layoutError.setVisibility(View.GONE); + recyclerLogs.setVisibility(View.VISIBLE); + } + + private void showEmpty() { + layoutLoading.setVisibility(View.GONE); + layoutEmpty.setVisibility(View.VISIBLE); + layoutError.setVisibility(View.GONE); + recyclerLogs.setVisibility(View.GONE); + + ((android.widget.TextView) findViewById(R.id.text_empty_title)).setText(R.string.log_empty_title); + ((android.widget.TextView) findViewById(R.id.text_empty_subtitle)).setText(R.string.log_empty_subtitle); + } + + private void showEmptyFiltered() { + layoutLoading.setVisibility(View.GONE); + layoutEmpty.setVisibility(View.VISIBLE); + layoutError.setVisibility(View.GONE); + recyclerLogs.setVisibility(View.GONE); + + ((android.widget.TextView) findViewById(R.id.text_empty_title)).setText(R.string.log_no_matches); + ((android.widget.TextView) findViewById(R.id.text_empty_subtitle)).setText(R.string.log_adjust_filters); + } + + private void showError(String message) { + layoutLoading.setVisibility(View.GONE); + layoutEmpty.setVisibility(View.GONE); + layoutError.setVisibility(View.VISIBLE); + recyclerLogs.setVisibility(View.GONE); + + ((android.widget.TextView) findViewById(R.id.text_error)).setText( + getString(R.string.log_error_detail, message)); + } + + private void scrollToBottom() { + if (adapter.getItemCount() > 0) { + recyclerLogs.smoothScrollToPosition(adapter.getItemCount() - 1); } + } - @Override - protected String doInBackground(Void... voids) { + private void showClearConfirmation() { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.log_clear_title) + .setMessage(R.string.log_clear_message) + .setPositiveButton(R.string.log_clear_confirm, (dialog, which) -> clearLogs()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void clearLogs() { + executor.execute(() -> { File logDir = new File(Environment.getExternalStorageDirectory(), LOG_FILE_DIR); File logFile = new File(logDir, LOG_FILE_NAME); - if (!logFile.exists()) { - return "No logs found at " + logFile.getAbsolutePath(); + // Save content for undo + if (logFile.exists()) { + try (BufferedReader br = new BufferedReader(new FileReader(logFile))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append("\n"); + } + deletedLogsContent = sb.toString(); + } catch (IOException e) { + deletedLogsContent = null; + } } - StringBuilder sb = new StringBuilder(); - try (BufferedReader br = new BufferedReader(new FileReader(logFile))) { - String line; - // Read last 2000 lines effectively? Or just read all for now. - // Log files are capped at 5MB, reading might take a moment but is okay for - // text. - while ((line = br.readLine()) != null) { - sb.append(line).append("\n"); + boolean success = !logFile.exists() || logFile.delete(); + + mainHandler.post(() -> { + if (success) { + allLogEntries.clear(); + applyFilters(); + + Snackbar snackbar = Snackbar.make(fabClear, R.string.log_cleared, Snackbar.LENGTH_LONG); + if (deletedLogsContent != null && !deletedLogsContent.isEmpty()) { + snackbar.setAction(R.string.log_undo, v -> restoreLogs()); + } + snackbar.setAnchorView(fabClear); + snackbar.show(); + } else { + Snackbar.make(fabClear, R.string.log_clear_failed, Snackbar.LENGTH_SHORT) + .setAnchorView(fabClear) + .show(); } + }); + }); + } + + private void restoreLogs() { + if (deletedLogsContent == null || deletedLogsContent.isEmpty()) { + return; + } + + executor.execute(() -> { + File logDir = new File(Environment.getExternalStorageDirectory(), LOG_FILE_DIR); + if (!logDir.exists()) { + logDir.mkdirs(); + } + File logFile = new File(logDir, LOG_FILE_NAME); + + boolean success = false; + try (FileWriter writer = new FileWriter(logFile)) { + writer.write(deletedLogsContent); + success = true; } catch (IOException e) { - return "Error reading logs: " + e.getMessage(); + // Failed to restore } - return sb.toString(); + + final boolean finalSuccess = success; + mainHandler.post(() -> { + if (finalSuccess) { + loadLogs(); + Snackbar.make(fabClear, R.string.log_restored, Snackbar.LENGTH_SHORT) + .setAnchorView(fabClear) + .show(); + } else { + Snackbar.make(fabClear, R.string.log_restore_failed, Snackbar.LENGTH_SHORT) + .setAnchorView(fabClear) + .show(); + } + deletedLogsContent = null; + }); + }); + } + + private void shareLogs() { + File logDir = new File(Environment.getExternalStorageDirectory(), LOG_FILE_DIR); + File logFile = new File(logDir, LOG_FILE_NAME); + + if (!logFile.exists()) { + Snackbar.make(fabClear, R.string.log_empty_title, Snackbar.LENGTH_SHORT) + .setAnchorView(fabClear) + .show(); + return; + } + + try { + Uri uri = FileProvider.getUriForFile(this, + getPackageName() + ".fileprovider", logFile); + + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + shareIntent.putExtra(Intent.EXTRA_STREAM, uri); + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(shareIntent, getString(R.string.log_share))); + } catch (Exception e) { + // Fallback to sharing as text + StringBuilder content = new StringBuilder(); + for (LogEntry entry : filteredLogEntries) { + content.append(entry.getRawLine()).append("\n"); + } + + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + shareIntent.putExtra(Intent.EXTRA_TEXT, content.toString()); + startActivity(Intent.createChooser(shareIntent, getString(R.string.log_share))); + } + } + + private void exportLogs() { + // For now, share as export (could implement SAF for proper file saving) + shareLogs(); + } + + private void copyAllLogs() { + if (filteredLogEntries.isEmpty()) { + Snackbar.make(fabClear, R.string.log_empty_title, Snackbar.LENGTH_SHORT) + .setAnchorView(fabClear) + .show(); + return; + } + + StringBuilder content = new StringBuilder(); + for (LogEntry entry : filteredLogEntries) { + content.append(entry.getRawLine()).append("\n"); } - @Override - protected void onPostExecute(String result) { - logTextView.setText(result); - // Scroll to bottom - scrollView.post(() -> scrollView.fullScroll(ScrollView.FOCUS_DOWN)); + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard != null) { + ClipData clip = ClipData.newPlainText("logs", content.toString()); + clipboard.setPrimaryClip(clip); + Snackbar.make(fabClear, R.string.log_copied, Snackbar.LENGTH_SHORT) + .setAnchorView(fabClear) + .show(); } } } diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 0000000..e970fa3 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_export.xml b/app/src/main/res/drawable/ic_export.xml new file mode 100644 index 0000000..46f9421 --- /dev/null +++ b/app/src/main/res/drawable/ic_export.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_scroll_bottom.xml b/app/src/main/res/drawable/ic_scroll_bottom.xml new file mode 100644 index 0000000..19a199d --- /dev/null +++ b/app/src/main/res/drawable/ic_scroll_bottom.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..21bb2f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_log_viewer.xml b/app/src/main/res/layout/activity_log_viewer.xml index 85ff875..4d5fd9d 100644 --- a/app/src/main/res/layout/activity_log_viewer.xml +++ b/app/src/main/res/layout/activity_log_viewer.xml @@ -1,51 +1,253 @@ - + android:fitsSystemWindows="true" + tools:context=".ui.LogViewerActivity"> - + + android:layout_height="wrap_content" + android:fitsSystemWindows="true" + app:liftOnScroll="true"> - - + android:layout_height="?attr/actionBarSize" + app:navigationIcon="@drawable/ic_back" + app:title="@string/log_viewer_title" + style="@style/Widget.CameraInterceptor.Toolbar" /> + + - + - -