Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ Complete map. Trust it.
| `app/(tabs)/(torrents)/` | Torrents tab as a nested stack: `index` list, `torrent/[hash]`, `torrent/files`, `torrent/manage-trackers`. Group is omitted from URLs → `/`, `/torrent/[hash]`. |
| `app/(tabs)/search.tsx` | Search tab: job polling UI, plugin/category/indexer filter chips, client-side sort, collapsing header. Optional auto-tag-by-tracker on add (`autoCategorizeByTracker` pref — tags Search downloads only; the key name is historical). |
| `app/(tabs)/transfer.tsx` | Transfer stats, global speed and seeding limits. |
| `app/(tabs)/logs.tsx` | Connectivity logs. `href: null` — reached from Settings → Advanced, not a visible tab. |
| `app/(tabs)/logs.tsx` | qBittorrent's own server-side application + peer log viewer (`logs/main`, `logs/peers` via `services/api/logs.ts`) — needs a live connection, shows a "not connected" placeholder otherwise. `href: null` — reached from Settings → Advanced ("Server Logs" row), not a visible tab. Not the app's own connectivity/diagnostic log — see `components/LogViewer.tsx` for that. |
| `app/(tabs)/rss/` | RSS Feeds tab (`index` tree + `feed` detail). `href` is null until connected **and** the server's `rss_processing_enabled` is on. Rules and settings screens do **not** go here — they live under Settings. |
| `app/(tabs)/settings/` | Settings tab as a nested stack. See sub-screens below. |
| `app/(tabs)/_layout.tsx` | Tab bar and tab gating. |
Expand Down Expand Up @@ -446,7 +446,11 @@ All PascalCase function components taking a `…Props` interface.
- **Visuals** — `SpeedGraph`, `CircularProgress`, `AnimatedProgressBar`,
`AnimatedButton`, `Confetti`.
- **Chrome / diagnostics** — `FocusAwareStatusBar`, `SettingRow`,
`QuickConnectPanel`, `LogViewer`, `DebugRow`, `SuperDebugPanel`.
`QuickConnectPanel`, `LogViewer` (modal viewer for the app's own in-memory
connectivity log, `services/connectivity-log.ts` — copy-to-clipboard via
`formatConnectivityLog()`; opened from Settings → Advanced's "View
Connectivity Logs" row; works with no live server connection, unlike
`app/(tabs)/logs.tsx`), `DebugRow`, `SuperDebugPanel`.

### API wrappers (`services/api/`)

Expand Down Expand Up @@ -480,8 +484,8 @@ Thin objects over `apiClient`.
before the iOS security-scoped access can lapse.
- **`query-client.ts`** — the shared TanStack `QueryClient`.
- **`color-theme-manager.ts`** — save/load/apply user color themes.
- **`connectivity-log.ts`** — in-memory ring log (`clogDebug/Info/Warn/Error(tag, msg)`).
- **`log-storage.ts`** — persisted entries for the Logs screen.
- **`connectivity-log.ts`** — in-memory ring log (`clogDebug/Info/Warn/Error(tag, msg)`),
displayed by `components/LogViewer.tsx`.

### Native modules (`modules/`)

Expand Down Expand Up @@ -806,3 +810,9 @@ Keep entries factual and current; if you find one that's no longer true
(`isInsecureCertAllowlistAvailable()`) that callers use to warn or hint in
the UI — don't assume a native module is present just because requiring it
didn't throw at JS-parse time.
- **"View Connectivity Logs" used to open qBittorrent's own server-log
viewer** (`app/(tabs)/logs.tsx`), which needs a live connection and shows
nothing when the app can't connect — exactly the scenario it's needed for
(issue #256). The app's real diagnostic trail (`services/connectivity-log.ts`)
was never wired to any screen; it's now shown by `components/LogViewer.tsx`,
opened from its own "View Connectivity Logs" row in Settings → Advanced.
22 changes: 21 additions & 1 deletion app/(tabs)/settings/advanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useTheme } from '@/context/ThemeContext';
import { useServer } from '@/context/ServerContext';
import { useToast } from '@/context/ToastContext';
import { FocusAwareStatusBar } from '@/components/FocusAwareStatusBar';
import { LogViewer } from '@/components/LogViewer';
import { storageService } from '@/services/storage';
import { apiClient } from '@/services/api/client';
import { applicationApi } from '@/services/api/application';
Expand All @@ -45,6 +46,7 @@ export default function AdvancedSettingsScreen() {
const [retryAttempts, setRetryAttempts] = useState<number>(3);
const [debugMode, setDebugMode] = useState(false);
const [connectionTimeout, setConnectionTimeout] = useState<number>(10000);
const [connectivityLogVisible, setConnectivityLogVisible] = useState(false);

const loadPreferences = async () => {
try {
Expand Down Expand Up @@ -350,6 +352,20 @@ export default function AdvancedSettingsScreen() {
/>
</View>
<View style={[styles.separator, { backgroundColor: colors.surfaceOutline }]} />
<TouchableOpacity
style={styles.settingRow}
onPress={() => setConnectivityLogVisible(true)}
activeOpacity={0.7}
>
<View style={styles.settingLeft}>
<Ionicons name="pulse-outline" size={22} color={colors.primary} />
<Text style={[styles.settingLabel, { color: colors.text }]}>
{t('screens.settings.viewConnectivityLogs')}
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<View style={[styles.separator, { backgroundColor: colors.surfaceOutline }]} />
<TouchableOpacity
style={styles.settingRow}
onPress={() => router.dismissTo('/(tabs)/logs')}
Expand All @@ -358,7 +374,7 @@ export default function AdvancedSettingsScreen() {
<View style={styles.settingLeft}>
<Ionicons name="document-text-outline" size={22} color={colors.primary} />
<Text style={[styles.settingLabel, { color: colors.text }]}>
{t('screens.settings.viewLogs')}
{t('screens.settings.viewServerLogs')}
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
Expand Down Expand Up @@ -422,6 +438,10 @@ export default function AdvancedSettingsScreen() {
<View style={{ height: 40 }} />
</ScrollView>
</View>
<LogViewer
visible={connectivityLogVisible}
onClose={() => setConnectivityLogVisible(false)}
/>
</>
);
}
Expand Down
3 changes: 0 additions & 3 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { TransferProvider } from '@/context/TransferContext';
import { ThemeProvider, useTheme } from '@/context/ThemeContext';
import { ToastProvider, useToast } from '@/context/ToastContext';
import { SearchCartProvider } from '@/context/SearchCartContext';
import { logStorage } from '@/services/log-storage';
import { storageService } from '@/services/storage';
import { apiClient } from '@/services/api/client';
import { setHapticsEnabled } from '@/utils/haptics';
Expand Down Expand Up @@ -324,8 +323,6 @@ function StackNavigator() {

export default function RootLayout() {
useEffect(() => {
logStorage.autoDeleteIfNeeded();

// Apply persisted preferences to global modules at cold start so they
// take effect immediately — before the user ever visits Settings.
storageService
Expand Down
177 changes: 98 additions & 79 deletions components/LogViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
/**
* LogViewer.tsx — Modal viewer for locally stored connectivity/debug logs with export support.
* LogViewer.tsx — Modal viewer for the in-memory connectivity/debug log
* (services/connectivity-log.ts), with copy-to-clipboard support.
*
* Key exports: LogViewer
* Known issues: None currently tracked.
*/
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
ScrollView,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { View, Text, StyleSheet, Modal, ScrollView, TouchableOpacity } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard';
import { useTheme } from '@/context/ThemeContext';
import { logStorage, StoredLogEntry } from '@/services/log-storage';
import { useToast } from '@/context/ToastContext';
import {
getConnectivityLog,
clearConnectivityLog,
formatConnectivityLog,
ConnectivityLogEntry,
ConnectivityLogLevel,
} from '@/services/connectivity-log';
import { spacing, borderRadius } from '@/constants/spacing';
import { shadows } from '@/constants/shadows';
import { typography } from '@/constants/typography';
Expand All @@ -32,80 +33,66 @@ interface LogViewerProps {
export function LogViewer({ visible, onClose, onClear, refreshTrigger }: LogViewerProps) {
const { colors } = useTheme();
const { t } = useTranslation();
const [logs, setLogs] = useState<StoredLogEntry[]>([]);
const [loading, setLoading] = useState(false);
const { showToast } = useToast();
const [logs, setLogs] = useState<ConnectivityLogEntry[]>([]);

const loadLogs = async () => {
setLoading(true);
try {
const storedLogs = await logStorage.getLogs();
if (storedLogs && storedLogs.length > 0) {
setLogs(storedLogs.sort((a, b) => b.id - a.id));
} else {
setLogs([]);
}
} catch {
setLogs([]);
} finally {
setLoading(false);
}
// In-memory, synchronous — no loading state needed.
const loadLogs = () => {
setLogs(getConnectivityLog().slice().reverse());
};

useEffect(() => {
if (visible) {
// Small delay to ensure logs are stored before loading
const timer = setTimeout(() => {
loadLogs();
}, 100);
return () => clearTimeout(timer);
} else {
// Don't clear logs when modal closes - keep them in storage
loadLogs();
}
}, [visible, refreshTrigger]);

const handleClear = async () => {
const handleClear = () => {
clearConnectivityLog();
setLogs([]);
if (onClear) {
onClear();
}
};

const handleCopy = async () => {
try {
await logStorage.clearLogs();
// Clear logs from state immediately - don't reload
setLogs([]);
if (onClear) {
onClear();
}
} catch (error) {
console.error('Failed to clear logs:', error);
// Even if there's an error, try to clear the UI
setLogs([]);
await Clipboard.setStringAsync(formatConnectivityLog());
showToast(t('toast.connectivityLogCopied'), 'success');
} catch {
showToast(t('errors.failedToCopyLog'), 'error');
}
};

const getLogTypeColor = (type: number): string => {
switch (type) {
case 1:
return colors.textSecondary; // Normal
case 2:
const getLogTypeColor = (level: ConnectivityLogLevel): string => {
switch (level) {
case 'DEBUG':
return colors.textSecondary;
case 'WARN':
return colors.warning;
case 4:
return colors.error; // Critical
case 'ERROR':
return colors.error;
default:
return colors.primary; // Info
return colors.primary; // INFO
}
};

const getLogTypeLabel = (type: number): string => {
switch (type) {
case 1:
return t('screens.logs.normal');
case 2:
return t('screens.logs.warning');
case 4:
return t('screens.logs.critical');
const getLogTypeLabel = (level: ConnectivityLogLevel): string => {
switch (level) {
case 'DEBUG':
return t('screens.settings.logLevelDebug');
case 'WARN':
return t('screens.settings.logLevelWarn');
case 'ERROR':
return t('screens.settings.logLevelError');
default:
return t('screens.logs.info');
return t('screens.settings.logLevelInfo');
}
};

const formatTimestamp = (timestamp: number): string => {
const date = new Date(timestamp * 1000);
// ConnectivityLogEntry.timestamp is Date.now() — already milliseconds.
const date = new Date(timestamp);
return date.toLocaleString();
};

Expand All @@ -130,6 +117,16 @@ export function LogViewer({ visible, onClose, onClear, refreshTrigger }: LogView
{t('screens.settings.logsTitle')}
</Text>
<View style={styles.headerButtons}>
<TouchableOpacity
onPress={handleCopy}
style={[styles.copyButton, { backgroundColor: colors.primary }]}
disabled={logs.length === 0}
>
<Ionicons name="copy-outline" size={20} color={colors.onAccent} />
<Text style={[styles.copyButtonText, { color: colors.onAccent }]}>
{t('common.copy')}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleClear}
style={[styles.clearButton, { backgroundColor: colors.error }]}
Expand All @@ -149,18 +146,14 @@ export function LogViewer({ visible, onClose, onClear, refreshTrigger }: LogView
</View>

{/* Logs Content */}
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : logs.length === 0 ? (
{logs.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons name="document-text-outline" size={64} color={colors.textSecondary} />
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{t('screens.settings.noLogsAvailable')}
</Text>
<Text style={[styles.emptySubtext, { color: colors.textSecondary }]}>
{t('screens.settings.logsAutoClear')}
{t('screens.settings.logsSessionOnly')}
</Text>
</View>
) : (
Expand All @@ -176,14 +169,24 @@ export function LogViewer({ visible, onClose, onClear, refreshTrigger }: LogView
styles.logEntry,
{
backgroundColor: colors.background,
borderLeftColor: getLogTypeColor(log.type),
borderLeftColor: getLogTypeColor(log.level),
},
]}
>
<View style={styles.logHeader}>
<View style={[styles.logTypeBadge, { backgroundColor: colors.primary }]}>
<Text style={[styles.logTypeText, { color: colors.onAccent }]}>
{getLogTypeLabel(log.type)}
<View style={styles.logHeaderLeft}>
<View
style={[
styles.logTypeBadge,
{ backgroundColor: getLogTypeColor(log.level) },
]}
>
<Text style={[styles.logTypeText, { color: colors.onAccent }]}>
{getLogTypeLabel(log.level)}
</Text>
</View>
<Text style={[styles.logTag, { color: colors.textSecondary }]}>
{log.tag}
</Text>
</View>
<Text style={[styles.logTimestamp, { color: colors.textSecondary }]}>
Expand Down Expand Up @@ -237,6 +240,18 @@ const styles = StyleSheet.create({
alignItems: 'center',
gap: spacing.sm,
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.small,
},
copyButtonText: {
...typography.bodyMedium,
fontWeight: '600',
},
clearButton: {
flexDirection: 'row',
alignItems: 'center',
Expand All @@ -257,12 +272,6 @@ const styles = StyleSheet.create({
scrollContent: {
padding: spacing.md,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
Expand Down Expand Up @@ -293,6 +302,11 @@ const styles = StyleSheet.create({
alignItems: 'center',
marginBottom: spacing.xs,
},
logHeaderLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
logTypeBadge: {
paddingHorizontal: spacing.sm,
paddingVertical: 2,
Expand All @@ -304,6 +318,11 @@ const styles = StyleSheet.create({
fontWeight: '600',
textTransform: 'uppercase',
},
logTag: {
...typography.caption,
fontSize: 11,
fontWeight: '600',
},
logTimestamp: {
...typography.caption,
fontSize: 12,
Expand Down
Loading
Loading