Prechádzať zdrojové kódy

data traffic report

Viktoriia 1 mesiac pred
rodič
commit
100d919384

+ 7 - 0
Route.tsx

@@ -118,9 +118,11 @@ import AddRegionsNewScreen from 'src/screens/InAppScreens/TravelsScreen/AddRegio
 import RegionsVisitedScreen from 'src/screens/InAppScreens/TravelsScreen/RegionsVisitedScreen';
 import { clearLocalDatabaseOnLogout } from 'src/watermelondb/features/chat/data/chat.repo';
 import { stopAutoSyncListener } from 'src/watermelondb/features/chat/networkSync';
+import DataReportScreen from 'src/screens/DataReportScreen';
 
 enableScreens();
 
+
 SplashScreen.preventAutoHideAsync();
 
 const ScreenStack = createStackNavigator();
@@ -702,7 +704,12 @@ const Route = () => {
               name={NAVIGATION_PAGES.EDIT_PERSONAL_INFO}
               component={EditPersonalInfo}
             />
+            <ScreenStack.Screen
+              name={NAVIGATION_PAGES.DATA_REPORT}
+              component={DataReportScreen}
+            />
           </ScreenStack.Navigator>
+
         )}
       </BottomTab.Screen>
     </BottomTab.Navigator>

+ 14 - 0
src/components/MenuDrawer/index.tsx

@@ -16,6 +16,8 @@ import SharingIcon from 'assets/icons/location-sharing.svg';
 import BagIcon from 'assets/icons/bag.svg';
 import OfflineIcon from 'assets/icons/map-offline.svg';
 import GearIcon from 'assets/icons/gear.svg';
+import ChartPieIcon from 'assets/icons/chart-pie.svg';
+
 
 import { APP_VERSION } from 'src/constants';
 import { SafeAreaView } from 'react-native-safe-area-context';
@@ -98,6 +100,18 @@ export const MenuDrawer = (props: any) => {
               })
             }
           />
+          <MenuButton
+            label="Data Traffic Report"
+            icon={<ChartPieIcon fill={Colors.DARK_BLUE} width={20} height={20} />}
+            red={false}
+            buttonFn={() =>
+              // @ts-ignore
+              navigation.navigate(NAVIGATION_PAGES.MENU_DRAWER, {
+                screen: NAVIGATION_PAGES.DATA_REPORT
+              })
+            }
+          />
+
         </View>
 
         <View style={styles.bottomMenu}>

+ 1 - 1
src/database/flagsService/index.ts

@@ -1,4 +1,4 @@
-import * as FileSystem from 'expo-file-system/legacy';
+import { FileSystem } from 'src/utils';
 import { API_HOST } from 'src/constants';
 
 const downloadFlag = async (flagId: number) => {

+ 1 - 1
src/db/index.ts

@@ -1,5 +1,5 @@
 import * as SQLite from 'expo-sqlite';
-import * as FileSystem from 'expo-file-system/legacy';
+import { FileSystem } from 'src/utils';
 import { Asset } from 'expo-asset';
 import { API_HOST } from 'src/constants';
 import { storage } from 'src/storage';

+ 1000 - 0
src/screens/DataReportScreen/index.tsx

@@ -0,0 +1,1000 @@
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import {
+  View,
+  Text,
+  StyleSheet,
+  TouchableOpacity,
+  Alert
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useFocusEffect } from '@react-navigation/native';
+import * as FileSystem from 'expo-file-system/legacy';
+import Share from 'react-native-share';
+import { Header, Input, PageWrapper } from 'src/components';
+import { Colors } from 'src/theme';
+import { getFontSize } from 'src/utils';
+import {
+  getTelemetryEvents,
+  clearTelemetryEvents,
+  getAmbientCacheSize,
+  sanitizeUrlForDisplay,
+  APP_OPEN_TIME,
+  TelemetryEvent
+} from 'src/utils/telemetry';
+import { FlashList } from '@shopify/flash-list';
+
+const formatBytes = (bytes: number, decimals = 2) => {
+  if (!bytes || bytes === 0) return '0 Bytes';
+  const k = 1024;
+  const dm = decimals < 0 ? 0 : decimals;
+  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+};
+
+interface GroupedTelemetry {
+  urlKey: string;
+  method: string;
+  caller: string;
+  count: number;
+  totalSize: number;
+  avgDuration: number;
+  type: string;
+  status: number;
+}
+
+export default function DataReportScreen() {
+  const [events, setEvents] = useState<TelemetryEvent[]>([]);
+  const [ambientCacheSize, setAmbientCacheSize] = useState(0);
+  const [searchQuery, setSearchQuery] = useState('');
+  const [activeTab, setActiveTab] = useState<'all' | 'api_request' | 'file_download'>('all');
+  const [expandedId, setExpandedId] = useState<string | null>(null);
+  const [viewMode, setViewMode] = useState<'grouped' | 'detailed'>('grouped');
+  const [timeRange, setTimeRange] = useState<'app_open' | 'hour' | 'day' | 'week'>('app_open');
+  const [expandedModules, setExpandedModules] = useState<Record<string, boolean>>({});
+
+  const loadData = useCallback(() => {
+    const allEvents = getTelemetryEvents();
+    setEvents(allEvents);
+    getAmbientCacheSize().then((size) => setAmbientCacheSize(size));
+  }, []);
+
+  useFocusEffect(
+    useCallback(() => {
+      loadData();
+    }, [loadData])
+  );
+
+  useEffect(() => {
+    const timer = setInterval(() => {
+      loadData();
+    }, 3000);
+    return () => clearInterval(timer);
+  }, [loadData]);
+
+  const handleClear = () => {
+    Alert.alert(
+      'Clear Logs',
+      'This will delete all recorded traffic logs. Proceed?',
+      [
+        { text: 'Cancel', style: 'cancel' },
+        {
+          text: 'Clear',
+          style: 'destructive',
+          onPress: () => {
+            clearTelemetryEvents();
+            loadData();
+          }
+        }
+      ]
+    );
+  };
+
+  const handleExportCSV = async () => {
+    if (events.length === 0) {
+      Alert.alert('No Logs', 'There are no logs to export.');
+      return;
+    }
+    
+    let csv = 'Timestamp,Type,Caller,Method,URL,ReqSize,ResSize,Status,DurationMs\n';
+    events.forEach((e) => {
+      const timeStr = new Date(e.timestamp).toISOString();
+      const urlEscaped = e.url.replace(/"/g, '""');
+      csv += `"${timeStr}","${e.type}","${e.caller}","${e.method || ''}","${urlEscaped}",${e.reqSize},${e.resSize},${e.status},${e.duration}\n`;
+    });
+
+    try {
+      const filename = `nomadmania_traffic_report_${Date.now()}.csv`;
+      const fileUri = `${FileSystem.cacheDirectory}${filename}`;
+      
+      await FileSystem.writeAsStringAsync(fileUri, csv, {
+        encoding: FileSystem.EncodingType.UTF8
+      });
+      
+      await Share.open({
+        url: fileUri,
+        type: 'text/csv',
+        filename: 'nomadmania_traffic_report',
+        title: 'Share CSV Traffic Report'
+      });
+    } catch (error: any) {
+      if (error && error.message && error.message.includes('User cancelled')) {
+        return;
+      }
+      console.error('Failed to export CSV file:', error);
+    }
+  };
+
+  const toggleExpand = (id: string) => {
+    setExpandedId(expandedId === id ? null : id);
+  };
+
+  const toggleModuleExpand = (moduleName: string) => {
+    setExpandedModules((prev) => ({
+      ...prev,
+      [moduleName]: !prev[moduleName]
+    }));
+  };
+
+  const timeFilteredEvents = useMemo(() => {
+    let cutoff = 0;
+    if (timeRange === 'app_open') {
+      cutoff = APP_OPEN_TIME;
+    } else if (timeRange === 'hour') {
+      cutoff = Date.now() - 3600 * 1000;
+    } else if (timeRange === 'day') {
+      cutoff = Date.now() - 24 * 3600 * 1000;
+    } else {
+      cutoff = 0; 
+    }
+    return events.filter((e) => e.timestamp >= cutoff);
+  }, [events, timeRange]);
+
+  const dynamicSummary = useMemo(() => {
+    let totalDownloaded = 0;
+    let totalUploaded = 0;
+    const callerBreakdown: Record<string, { count: number; downloaded: number }> = {};
+
+    timeFilteredEvents.forEach((event) => {
+      totalDownloaded += event.resSize;
+      totalUploaded += event.reqSize;
+
+      const caller = event.caller || 'Other';
+      if (!callerBreakdown[caller]) {
+        callerBreakdown[caller] = { count: 0, downloaded: 0 };
+      }
+      callerBreakdown[caller].count += 1;
+      callerBreakdown[caller].downloaded += event.resSize;
+    });
+
+    const breakdown = Object.entries(callerBreakdown)
+      .map(([name, data]) => ({
+        name,
+        count: data.count,
+        downloaded: data.downloaded
+      }))
+      .sort((a, b) => b.downloaded - a.downloaded);
+
+    return {
+      totalDownloaded,
+      totalUploaded,
+      totalEvents: timeFilteredEvents.length,
+      callerBreakdown: breakdown
+    };
+  }, [timeFilteredEvents]);
+
+  const filteredEvents = useMemo(() => {
+    return timeFilteredEvents.filter((e) => {
+      const matchesTab = activeTab === 'all' || e.type === activeTab;
+      const matchesSearch =
+        e.url.toLowerCase().includes(searchQuery.toLowerCase()) ||
+        e.caller.toLowerCase().includes(searchQuery.toLowerCase());
+      return matchesTab && matchesSearch;
+    });
+  }, [timeFilteredEvents, activeTab, searchQuery]);
+
+  const groupedEvents = useMemo(() => {
+    const groups: Record<string, GroupedTelemetry> = {};
+    
+    filteredEvents.forEach((e) => {
+      const displayUrl = sanitizeUrlForDisplay(e.url);
+      const key = `${e.method || ''}:${displayUrl}`;
+      
+      if (!groups[key]) {
+        groups[key] = {
+          urlKey: displayUrl,
+          method: e.method || 'GET',
+          caller: e.caller,
+          count: 0,
+          totalSize: 0,
+          avgDuration: 0,
+          type: e.type,
+          status: e.status
+        };
+      }
+      
+      const g = groups[key];
+      g.count += 1;
+      g.totalSize += (e.resSize + e.reqSize);
+      g.avgDuration += e.duration;
+      g.status = e.status;
+    });
+    
+    return Object.values(groups).map((g) => ({
+      ...g,
+      avgDuration: Math.round(g.avgDuration / g.count)
+    })).sort((a, b) => b.totalSize - a.totalSize);
+  }, [filteredEvents]);
+
+  const modulesBreakdown = useMemo(() => {
+    const groups: Record<string, { name: string; totalSize: number; count: number; items: GroupedTelemetry[] }> = {};
+    
+    groupedEvents.forEach((item) => {
+      const moduleName = item.caller;
+      if (!groups[moduleName]) {
+        groups[moduleName] = {
+          name: moduleName,
+          totalSize: 0,
+          count: 0,
+          items: []
+        };
+      }
+      const g = groups[moduleName];
+      g.totalSize += item.totalSize;
+      g.count += item.count;
+      g.items.push(item);
+    });
+    
+    return Object.values(groups).sort((a, b) => b.totalSize - a.totalSize);
+  }, [groupedEvents]);
+
+  const renderEventItem = ({ item }: { item: TelemetryEvent }) => {
+    const isExpanded = expandedId === item.id;
+    const dateStr = new Date(item.timestamp).toLocaleTimeString();
+    const totalSize = item.resSize + item.reqSize;
+    const isError = item.status >= 400 || item.status === 0;
+
+    return (
+      <TouchableOpacity
+        style={[styles.eventCard, isExpanded && styles.eventCardExpanded]}
+        onPress={() => toggleExpand(item.id)}
+        activeOpacity={0.7}
+      >
+        <View style={styles.eventHeader}>
+          <View style={styles.eventLeft}>
+            <View
+              style={[
+                styles.typeBadge,
+                item.type === 'file_download' ? styles.badgeDownload : styles.badgeApi
+              ]}
+            >
+              <Text style={styles.typeBadgeText}>
+                {item.type === 'file_download' ? 'FILE' : 'API'}
+              </Text>
+            </View>
+            <View style={styles.eventTitleContainer}>
+              <Text style={styles.eventCaller} numberOfLines={1}>
+                {item.caller}
+              </Text>
+              <Text style={styles.eventUrlShort} numberOfLines={1}>
+                {item.url.split('/').pop() || item.url}
+              </Text>
+            </View>
+          </View>
+          <View style={styles.eventRight}>
+            <Text style={styles.eventSize}>{formatBytes(totalSize)}</Text>
+            <Text style={styles.eventTime}>{dateStr}</Text>
+          </View>
+        </View>
+
+        {isExpanded && (
+          <View style={styles.expandedContent}>
+            <View style={styles.divider} />
+            <Text style={styles.detailLabel}>Full URL / Path:</Text>
+            <Text style={styles.detailValueSelectable} selectable>
+              {item.url}
+            </Text>
+
+            <View style={styles.detailGrid}>
+              <View style={styles.gridItem}>
+                <Text style={styles.detailLabel}>Method:</Text>
+                <Text style={styles.detailValue}>{item.method || 'GET'}</Text>
+              </View>
+              <View style={styles.gridItem}>
+                <Text style={styles.detailLabel}>Status:</Text>
+                <Text style={[styles.detailValue, isError ? styles.textRed : styles.textGreen]}>
+                  {item.status}
+                </Text>
+              </View>
+              <View style={styles.gridItem}>
+                <Text style={styles.detailLabel}>Duration:</Text>
+                <Text style={styles.detailValue}>{item.duration}ms</Text>
+              </View>
+            </View>
+
+            <View style={styles.detailGrid}>
+              <View style={styles.gridItem}>
+                <Text style={styles.detailLabel}>Upload size:</Text>
+                <Text style={styles.detailValue}>{formatBytes(item.reqSize)}</Text>
+              </View>
+              <View style={styles.gridItem}>
+                <Text style={styles.detailLabel}>Download size:</Text>
+                <Text style={styles.detailValue}>{formatBytes(item.resSize)}</Text>
+              </View>
+              <View style={styles.gridItem}>
+                <Text style={styles.detailLabel}>Timestamp:</Text>
+                <Text style={styles.detailValue}>
+                  {new Date(item.timestamp).toLocaleString()}
+                </Text>
+              </View>
+            </View>
+          </View>
+        )}
+      </TouchableOpacity>
+    );
+  };
+
+  const renderModuleItem = ({ item }: { item: any }) => {
+    const isExpanded = !!expandedModules[item.name];
+    const totalSize = item.totalSize;
+
+    return (
+      <View style={styles.moduleWrapper}>
+        <TouchableOpacity
+          style={[styles.moduleHeader, isExpanded && styles.moduleHeaderExpanded]}
+          onPress={() => toggleModuleExpand(item.name)}
+          activeOpacity={0.7}
+        >
+          <View style={styles.moduleHeaderLeft}>
+            <Ionicons
+              name={isExpanded ? 'chevron-down-outline' : 'chevron-forward-outline'}
+              size={18}
+              color={Colors.DARK_BLUE}
+              style={{ marginRight: 8 }}
+            />
+            <Text style={styles.moduleName} numberOfLines={1}>{item.name}</Text>
+            <View style={styles.countBadge}>
+              <Text style={styles.countBadgeText}>{item.count}</Text>
+            </View>
+          </View>
+          <Text style={styles.moduleSize}>{formatBytes(totalSize)}</Text>
+        </TouchableOpacity>
+
+        {isExpanded && (
+          <View style={styles.moduleItemsContainer}>
+            {item.items.map((subItem: GroupedTelemetry, index: number) => {
+              const isError = subItem.status >= 400 || subItem.status === 0;
+              return (
+                <View key={index} style={styles.subItemCard}>
+                  <View style={styles.subItemHeader}>
+                    <View style={{ flex: 1, marginRight: 8 }}>
+                      <Text style={styles.subItemUrl} numberOfLines={2}>
+                        {subItem.method} {subItem.urlKey}
+                      </Text>
+                    </View>
+                    <View style={{ alignItems: 'flex-end' }}>
+                      <Text style={styles.subItemSize}>{formatBytes(subItem.totalSize)}</Text>
+                      <Text style={styles.subItemCount}>x{subItem.count}</Text>
+                    </View>
+                  </View>
+                  <View style={styles.subItemFooter}>
+                    <Text style={[styles.subItemStatus, isError ? styles.textRed : styles.textGreen]}>
+                      Status: {subItem.status}
+                    </Text>
+                    <Text style={styles.subItemDuration}>
+                      avg {subItem.avgDuration}ms
+                    </Text>
+                  </View>
+                </View>
+              );
+            })}
+          </View>
+        )}
+      </View>
+    );
+  };
+
+  const renderHeader = () => {
+    return (
+      <View style={styles.headerContainer}>
+        <View style={styles.summaryContainer}>
+          <View style={styles.summaryRow}>
+            <View style={styles.summaryCard}>
+              <Ionicons name="cloud-download-outline" size={20} color={Colors.ORANGE} />
+              <Text style={styles.summaryValue}>
+                {formatBytes(dynamicSummary.totalDownloaded)}
+              </Text>
+              <Text style={styles.summaryLabel}>Downloaded</Text>
+            </View>
+            
+            <View style={styles.summaryCard}>
+              <Ionicons name="swap-vertical-outline" size={20} color={Colors.DARK_BLUE} />
+              <Text style={styles.summaryValue}>{dynamicSummary.totalEvents}</Text>
+              <Text style={styles.summaryLabel}>Requests</Text>
+            </View>
+
+            <View style={styles.summaryCard}>
+              <Ionicons name="map-outline" size={20} color="#28A745" />
+              <Text style={styles.summaryValue}>
+                {formatBytes(ambientCacheSize)}
+              </Text>
+              <Text style={styles.summaryLabel}>Map Cache</Text>
+            </View>
+          </View>
+
+          {dynamicSummary?.callerBreakdown?.length > 0 && (
+            <View style={styles.breakdownContainer}>
+              <Text style={styles.sectionTitle}>Breakdown by Module</Text>
+              {dynamicSummary.callerBreakdown.slice(0, 5).map((callerItem: any, index: number) => {
+                const percentage = dynamicSummary.totalDownloaded > 0 
+                  ? (callerItem.downloaded / dynamicSummary.totalDownloaded) * 100 
+                  : 0;
+                return (
+                  <View key={callerItem.name} style={styles.breakdownRow}>
+                    <View style={styles.breakdownTextRow}>
+                      <Text style={styles.breakdownName} numberOfLines={1}>{callerItem.name}</Text>
+                      <Text style={styles.breakdownValue}>
+                        {formatBytes(callerItem.downloaded)} ({Math.round(percentage)}%)
+                      </Text>
+                    </View>
+                    <View style={styles.progressBarBg}>
+                      <View 
+                        style={[
+                          styles.progressBarFill, 
+                          { width: `${percentage}%`, backgroundColor: index === 0 ? Colors.ORANGE : Colors.DARK_BLUE }
+                        ]} 
+                      />
+                    </View>
+                  </View>
+                );
+              })}
+            </View>
+          )}
+        </View>
+
+        <View style={styles.timeRangeContainer}>
+          <Text style={styles.timeRangeLabel}>Report Period:</Text>
+          <View style={styles.timeRangeButtons}>
+            {(
+              [
+                { label: 'Session', value: 'app_open' },
+                { label: '1 Hour', value: 'hour' },
+                { label: '24 Hours', value: 'day' },
+                { label: '7 Days', value: 'week' }
+              ] as const
+            ).map((opt) => (
+              <TouchableOpacity
+                key={opt.value}
+                style={[styles.timeBtn, timeRange === opt.value && styles.timeBtnActive]}
+                onPress={() => setTimeRange(opt.value)}
+              >
+                <Text style={[styles.timeBtnText, timeRange === opt.value && styles.timeBtnTextActive]}>
+                  {opt.label}
+                </Text>
+              </TouchableOpacity>
+            ))}
+          </View>
+        </View>
+
+        <View style={styles.actionRow}>
+          <TouchableOpacity style={[styles.actionBtn, styles.btnOutline]} onPress={handleExportCSV}>
+            <Ionicons name="share-outline" size={16} color={Colors.DARK_BLUE} style={{ marginRight: 6 }} />
+            <Text style={styles.btnOutlineText}>Share CSV</Text>
+          </TouchableOpacity>
+
+          <TouchableOpacity style={[styles.actionBtn, styles.btnDestructive]} onPress={handleClear}>
+            <Ionicons name="trash-outline" size={16} color={Colors.WHITE} style={{ marginRight: 6 }} />
+            <Text style={styles.btnDestructiveText}>Clear Logs</Text>
+          </TouchableOpacity>
+        </View>
+
+        <View style={styles.modeContainer}>
+          <TouchableOpacity
+            style={[styles.modeButton, viewMode === 'grouped' && styles.modeActive]}
+            onPress={() => setViewMode('grouped')}
+          >
+            <Text style={[styles.modeText, viewMode === 'grouped' && styles.modeTextActive]}>Grouped View</Text>
+          </TouchableOpacity>
+          <TouchableOpacity
+            style={[styles.modeButton, viewMode === 'detailed' && styles.modeActive]}
+            onPress={() => setViewMode('detailed')}
+          >
+            <Text style={[styles.modeText, viewMode === 'detailed' && styles.modeTextActive]}>Detailed Logs</Text>
+          </TouchableOpacity>
+        </View>
+
+        <View style={styles.tabContainer}>
+          <TouchableOpacity
+            style={[styles.tabButton, activeTab === 'all' && styles.tabActive]}
+            onPress={() => setActiveTab('all')}
+          >
+            <Text style={[styles.tabText, activeTab === 'all' && styles.tabTextActive]}>All</Text>
+          </TouchableOpacity>
+          <TouchableOpacity
+            style={[styles.tabButton, activeTab === 'api_request' && styles.tabActive]}
+            onPress={() => setActiveTab('api_request')}
+          >
+            <Text style={[styles.tabText, activeTab === 'api_request' && styles.tabTextActive]}>APIs</Text>
+          </TouchableOpacity>
+          <TouchableOpacity
+            style={[styles.tabButton, activeTab === 'file_download' && styles.tabActive]}
+            onPress={() => setActiveTab('file_download')}
+          >
+            <Text style={[styles.tabText, activeTab === 'file_download' && styles.tabTextActive]}>Downloads</Text>
+          </TouchableOpacity>
+        </View>
+
+        <View style={styles.searchContainer}>
+          <Input
+            value={searchQuery}
+            onChange={setSearchQuery}
+            placeholder="Search by URL or module..."
+            inputMode="text"
+          />
+        </View>
+      </View>
+    );
+  };
+
+  return (
+    <PageWrapper style={styles.wrapperStyle}>
+      <Header label="Data Traffic Report" />
+      
+      <FlashList
+        estimatedItemSize={50}
+        style={styles.flexOne}
+        data={viewMode === 'grouped' ? modulesBreakdown : filteredEvents}
+        renderItem={viewMode === 'grouped' ? renderModuleItem : renderEventItem}
+        keyExtractor={(item) => viewMode === 'grouped' ? (item as any).name : (item as TelemetryEvent).id}
+        ListHeaderComponent={renderHeader}
+        contentContainerStyle={styles.listContainer}
+        ListEmptyComponent={
+          <View style={styles.emptyContainer}>
+            <Ionicons name="bar-chart-outline" size={40} color={Colors.LIGHT_GRAY} />
+            <Text style={styles.emptyText}>No data traffic recorded yet</Text>
+            <Text style={styles.emptySubText}>
+              Any API calls or database downloads will show up here in real-time.
+            </Text>
+          </View>
+        }
+      />
+    </PageWrapper>
+  );
+}
+
+const styles = StyleSheet.create({
+  flexOne: {
+    flex: 1
+  },
+  wrapperStyle: {
+    flex: 1,
+  },
+  headerContainer: {
+    paddingHorizontal: 2
+  },
+  summaryContainer: {
+    backgroundColor: Colors.FILL_LIGHT,
+    borderRadius: 8,
+    padding: 16,
+    marginBottom: 12
+  },
+  summaryRow: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    gap: 10
+  },
+  summaryCard: {
+    flex: 1,
+    backgroundColor: Colors.WHITE,
+    borderRadius: 6,
+    padding: 10,
+    alignItems: 'center',
+    borderColor: '#eef2f3',
+    borderWidth: 1
+  },
+  summaryValue: {
+    fontSize: getFontSize(13),
+    fontFamily: 'redhat-700',
+    color: Colors.DARK_BLUE,
+    marginTop: 4
+  },
+  summaryLabel: {
+    fontSize: getFontSize(10),
+    color: Colors.TEXT_GRAY,
+    marginTop: 2
+  },
+  breakdownContainer: {
+    marginTop: 16,
+    borderTopWidth: 1,
+    borderTopColor: 'rgba(0, 0, 0, 0.05)',
+    paddingTop: 12
+  },
+  sectionTitle: {
+    fontSize: getFontSize(12),
+    fontFamily: 'redhat-700',
+    color: Colors.DARK_BLUE,
+    marginBottom: 10
+  },
+  breakdownRow: {
+    marginBottom: 8
+  },
+  breakdownTextRow: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    marginBottom: 4
+  },
+  breakdownName: {
+    fontSize: getFontSize(11),
+    color: Colors.DARK_BLUE,
+    fontWeight: '600',
+    flex: 1,
+    marginRight: 8
+  },
+  breakdownValue: {
+    fontSize: getFontSize(11),
+    color: Colors.TEXT_GRAY
+  },
+  progressBarBg: {
+    height: 6,
+    backgroundColor: 'rgba(0,0,0,0.06)',
+    borderRadius: 3,
+    overflow: 'hidden'
+  },
+  progressBarFill: {
+    height: '100%',
+    borderRadius: 3
+  },
+  timeRangeContainer: {
+    marginBottom: 12,
+    backgroundColor: Colors.FILL_LIGHT,
+    borderRadius: 8,
+    padding: 12
+  },
+  timeRangeLabel: {
+    fontSize: getFontSize(11),
+    fontFamily: 'redhat-700',
+    color: Colors.DARK_BLUE,
+    marginBottom: 6
+  },
+  timeRangeButtons: {
+    flexDirection: 'row',
+    gap: 6
+  },
+  timeBtn: {
+    flex: 1,
+    paddingVertical: 6,
+    borderRadius: 4,
+    backgroundColor: Colors.WHITE,
+    borderWidth: 1,
+    borderColor: '#EAEEF0',
+    alignItems: 'center'
+  },
+  timeBtnActive: {
+    backgroundColor: Colors.DARK_BLUE,
+    borderColor: Colors.DARK_BLUE
+  },
+  timeBtnText: {
+    fontSize: getFontSize(10),
+    color: Colors.TEXT_GRAY,
+    fontFamily: 'montserrat-600'
+  },
+  timeBtnTextActive: {
+    color: Colors.WHITE,
+    fontFamily: 'montserrat-700'
+  },
+  actionRow: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    gap: 12,
+    marginBottom: 12
+  },
+  actionBtn: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    paddingVertical: 10,
+    borderRadius: 4
+  },
+  btnOutline: {
+    borderColor: Colors.DARK_BLUE,
+    borderWidth: 1
+  },
+  btnOutlineText: {
+    color: Colors.DARK_BLUE,
+    fontSize: getFontSize(12),
+    fontFamily: 'redhat-700'
+  },
+  btnDestructive: {
+    backgroundColor: Colors.RED
+  },
+  btnDestructiveText: {
+    color: Colors.WHITE,
+    fontSize: getFontSize(12),
+    fontFamily: 'redhat-700'
+  },
+  modeContainer: {
+    flexDirection: 'row',
+    borderWidth: 1,
+    borderColor: Colors.BORDER_LIGHT,
+    borderRadius: 6,
+    overflow: 'hidden',
+    marginBottom: 12
+  },
+  modeButton: {
+    flex: 1,
+    paddingVertical: 10,
+    alignItems: 'center',
+    backgroundColor: Colors.WHITE
+  },
+  modeActive: {
+    backgroundColor: Colors.DARK_BLUE
+  },
+  modeText: {
+    fontSize: getFontSize(12),
+    color: Colors.DARK_BLUE,
+    fontFamily: 'redhat-700'
+  },
+  modeTextActive: {
+    color: Colors.WHITE
+  },
+  tabContainer: {
+    flexDirection: 'row',
+    backgroundColor: '#EAEEF0',
+    borderRadius: 6,
+    padding: 3,
+    marginBottom: 12
+  },
+  tabButton: {
+    flex: 1,
+    paddingVertical: 8,
+    alignItems: 'center',
+    borderRadius: 4
+  },
+  tabActive: {
+    backgroundColor: Colors.WHITE
+  },
+  tabText: {
+    fontSize: getFontSize(12),
+    color: Colors.TEXT_GRAY,
+    fontFamily: 'montserrat-600'
+  },
+  tabTextActive: {
+    color: Colors.DARK_BLUE,
+    fontFamily: 'montserrat-700'
+  },
+  searchContainer: {
+    marginBottom: 10
+  },
+  listContainer: {
+    paddingBottom: 24
+  },
+  eventCard: {
+    backgroundColor: Colors.FILL_LIGHT,
+    borderRadius: 6,
+    padding: 12,
+    marginBottom: 8,
+    borderWidth: 1,
+    borderColor: 'transparent'
+  },
+  eventCardExpanded: {
+    borderColor: Colors.BORDER_LIGHT,
+    backgroundColor: Colors.WHITE
+  },
+  eventHeader: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center'
+  },
+  eventLeft: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    flex: 1,
+    marginRight: 8
+  },
+  typeBadge: {
+    paddingHorizontal: 6,
+    paddingVertical: 3,
+    borderRadius: 4,
+    marginRight: 8
+  },
+  badgeApi: {
+    backgroundColor: '#EBF4F6'
+  },
+  badgeDownload: {
+    backgroundColor: '#FDF3E7'
+  },
+  typeBadgeText: {
+    fontSize: getFontSize(8),
+    fontFamily: 'redhat-700',
+    color: Colors.DARK_BLUE
+  },
+  eventTitleContainer: {
+    flex: 1
+  },
+  eventCaller: {
+    fontSize: getFontSize(12),
+    fontFamily: 'redhat-700',
+    color: Colors.DARK_BLUE
+  },
+  eventUrlShort: {
+    fontSize: getFontSize(10),
+    color: Colors.TEXT_GRAY,
+    marginTop: 1
+  },
+  eventRight: {
+    alignItems: 'flex-end'
+  },
+  eventSize: {
+    fontSize: getFontSize(12),
+    fontFamily: 'redhat-700',
+    color: Colors.DARK_BLUE
+  },
+  eventTime: {
+    fontSize: getFontSize(10),
+    color: Colors.TEXT_GRAY,
+    marginTop: 1
+  },
+  expandedContent: {
+    marginTop: 10
+  },
+  divider: {
+    height: 1,
+    backgroundColor: '#EAEEF0',
+    marginBottom: 10
+  },
+  detailLabel: {
+    fontSize: getFontSize(10),
+    color: Colors.TEXT_GRAY,
+    fontWeight: '600',
+    marginBottom: 2
+  },
+  detailValue: {
+    fontSize: getFontSize(11),
+    color: Colors.DARK_BLUE,
+    fontWeight: '500'
+  },
+  detailValueSelectable: {
+    fontSize: getFontSize(11),
+    color: Colors.DARK_BLUE,
+    fontWeight: '500',
+    backgroundColor: Colors.FILL_LIGHT,
+    padding: 6,
+    borderRadius: 4,
+    marginBottom: 10
+  },
+  detailGrid: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    marginBottom: 10
+  },
+  gridItem: {
+    flex: 1
+  },
+  textRed: {
+    color: Colors.RED,
+    fontFamily: 'redhat-700'
+  },
+  textGreen: {
+    color: '#28A745',
+    fontFamily: 'redhat-700'
+  },
+  emptyContainer: {
+    alignItems: 'center',
+    justifyContent: 'center',
+    paddingVertical: 60,
+    gap: 8
+  },
+  emptyText: {
+    fontSize: getFontSize(13),
+    fontFamily: 'redhat-700',
+    color: Colors.DARK_BLUE,
+    marginTop: 10
+  },
+  emptySubText: {
+    fontSize: getFontSize(11),
+    color: Colors.TEXT_GRAY,
+    textAlign: 'center',
+    paddingHorizontal: 20
+  },
+  moduleWrapper: {
+    marginBottom: 10,
+    borderRadius: 8,
+    overflow: 'hidden',
+    backgroundColor: Colors.FILL_LIGHT,
+    borderWidth: 1,
+    borderColor: '#EAEEF0'
+  },
+  moduleHeader: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    padding: 14,
+    backgroundColor: Colors.FILL_LIGHT
+  },
+  moduleHeaderExpanded: {
+    borderBottomWidth: 1,
+    borderBottomColor: '#EAEEF0',
+    backgroundColor: Colors.WHITE
+  },
+  moduleHeaderLeft: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    flex: 1,
+    marginRight: 8
+  },
+  moduleName: {
+    fontSize: getFontSize(13),
+    fontFamily: 'redhat-700',
+    color: Colors.DARK_BLUE,
+    marginRight: 8,
+    flexShrink: 1
+  },
+  moduleSize: {
+    fontSize: getFontSize(13),
+    fontFamily: 'redhat-700',
+    color: Colors.DARK_BLUE
+  },
+  moduleItemsContainer: {
+    padding: 10,
+    backgroundColor: Colors.WHITE,
+    gap: 8
+  },
+  subItemCard: {
+    backgroundColor: Colors.FILL_LIGHT,
+    borderRadius: 6,
+    padding: 10,
+    borderWidth: 1,
+    borderColor: '#EAEEF0'
+  },
+  subItemHeader: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'flex-start',
+    marginBottom: 6
+  },
+  subItemUrl: {
+    fontSize: getFontSize(11),
+    color: Colors.DARK_BLUE,
+    fontFamily: 'montserrat-600'
+  },
+  subItemSize: {
+    fontSize: getFontSize(11),
+    fontFamily: 'redhat-700',
+    color: Colors.DARK_BLUE
+  },
+  subItemCount: {
+    fontSize: getFontSize(10),
+    color: Colors.TEXT_GRAY,
+    marginTop: 1
+  },
+  subItemFooter: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    borderTopWidth: 1,
+    borderTopColor: '#EAEEF0',
+    paddingTop: 6,
+    marginTop: 2
+  },
+  subItemStatus: {
+    fontSize: getFontSize(10),
+    fontWeight: '600'
+  },
+  subItemDuration: {
+    fontSize: getFontSize(10),
+    color: Colors.TEXT_GRAY,
+    fontWeight: '500'
+  },
+  countBadge: {
+    backgroundColor: Colors.DARK_BLUE,
+    borderRadius: 10,
+    paddingHorizontal: 6,
+    paddingVertical: 2
+  },
+  countBadgeText: {
+    color: Colors.WHITE,
+    fontSize: getFontSize(9),
+    fontWeight: '700'
+  }
+});

+ 1 - 1
src/screens/InAppScreens/MapScreen/index.tsx

@@ -52,7 +52,7 @@ import { useGetUniversalSearch } from '@api/search';
 import { fetchCountryUserData, useGetListCountriesQuery } from '@api/countries';
 import SearchModal from './UniversalSearch';
 import EditModal from '../TravelsScreen/Components/EditSlowModal';
-import * as FileSystem from 'expo-file-system/legacy';
+import { FileSystem } from 'src/utils';
 
 import moment from 'moment';
 import {

+ 1 - 1
src/screens/InAppScreens/MessagesScreen/ChatScreen/index.tsx

@@ -64,7 +64,7 @@ import {
 } from '../utils';
 import { useMessagesStore } from 'src/stores/unreadMessagesStore';
 import FileViewer from 'react-native-file-viewer';
-import * as FileSystem from 'expo-file-system/legacy';
+import { FileSystem } from 'src/utils';
 import Share from 'react-native-share';
 
 import BanIcon from 'assets/icons/messages/ban.svg';

+ 1 - 1
src/screens/InAppScreens/MessagesScreen/Components/RenderMessageImage.tsx

@@ -1,6 +1,6 @@
 import React, { useState, useEffect } from 'react';
 import { View, ActivityIndicator, TouchableOpacity, Platform, Image } from 'react-native';
-import * as FileSystem from 'expo-file-system/legacy';
+import { FileSystem } from 'src/utils';
 import { Colors } from 'src/theme';
 import { CACHED_ATTACHMENTS_DIR } from 'src/constants/constants';
 import { API_HOST, APP_VERSION } from 'src/constants';

+ 1 - 1
src/screens/InAppScreens/MessagesScreen/Components/renderMessageVideo.tsx

@@ -3,7 +3,7 @@ import { View, ActivityIndicator, TouchableOpacity, Platform, Image } from 'reac
 import { useEvent } from 'expo';
 import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video';
 import { MaterialCommunityIcons } from '@expo/vector-icons';
-import * as FileSystem from 'expo-file-system/legacy';
+import { FileSystem } from 'src/utils';
 import { Colors } from 'src/theme';
 import { CACHED_ATTACHMENTS_DIR } from 'src/constants/constants';
 import { API_HOST, APP_VERSION } from 'src/constants';

+ 1 - 1
src/screens/InAppScreens/MessagesScreen/GroupChatScreen/index.tsx

@@ -75,7 +75,7 @@ import {
 } from '../utils';
 import { useMessagesStore } from 'src/stores/unreadMessagesStore';
 import FileViewer from 'react-native-file-viewer';
-import * as FileSystem from 'expo-file-system/legacy';
+import { FileSystem } from 'src/utils';
 import Share from 'react-native-share';
 
 import BanIcon from 'assets/icons/messages/ban.svg';

+ 1 - 1
src/screens/InAppScreens/MessagesScreen/utils.ts

@@ -4,7 +4,7 @@ import { Alert, Platform } from 'react-native';
 import { NAVIGATION_PAGES } from 'src/types';
 import { storage, StoreType } from 'src/storage';
 import { Image as ImageCompressor, Video as VideoCompressor } from 'react-native-compressor';
-import * as FileSystem from 'expo-file-system/legacy';
+import { FileSystem } from 'src/utils';
 import Share from 'react-native-share';
 import { APP_VERSION } from 'src/constants';
 

+ 1 - 1
src/screens/InAppScreens/TravelsScreen/EventScreen/index.tsx

@@ -15,7 +15,7 @@ import { styles } from './styles';
 import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native';
 import { Colors } from 'src/theme';
 import FileViewer from 'react-native-file-viewer';
-import * as FileSystem from 'expo-file-system/legacy';
+import { FileSystem } from 'src/utils';
 import * as DocumentPicker from '@react-native-documents/picker';
 import * as ImagePicker from 'expo-image-picker';
 

+ 55 - 12
src/screens/OfflineMapsScreen/OfflineMapManager.ts

@@ -3,6 +3,13 @@ import { AppState, Platform } from 'react-native';
 import NetInfo from '@react-native-community/netinfo';
 import { MMKV } from 'react-native-mmkv';
 import { VECTOR_MAP_HOST } from 'src/constants';
+import { addTelemetryEvent } from 'src/utils/telemetry';
+
+const packLastSize = new Map<string, number>();
+const packLastUpdatePercent = new Map<string, number>();
+const packLastUpdateTime = new Map<string, number>();
+
+
 
 const storage = new MMKV();
 const OFFLINE_MAPS_KEY = 'offline_maps';
@@ -125,19 +132,52 @@ const subscribeToPackProgress = (packName, progressCallback, errorCallback) => {
           ? (status.completedResourceCount / status.requiredResourceCount) * 100
           : 0;
 
-      updateMapMetadata(packName, {
-        progress: percentage,
-        size: status.completedResourceSize || 0
-      });
-
-      if (progressCallbacks[packName]) {
-        progressCallbacks[packName]({
-          name: packName,
-          percentage,
-          completedSize: status.completedResourceSize,
-          completedResourceCount: status.completedResourceCount,
-          requiredResourceCount: status.requiredResourceCount
+      const isCompleted = status.completedResourceCount === status.requiredResourceCount;
+      const lastPercent = packLastUpdatePercent.get(packName) || 0;
+      const lastTime = packLastUpdateTime.get(packName) || 0;
+      const now = Date.now();
+
+      // Only perform heavy updates (UI, MMKV writes, Telemetry logging) if:
+      // 1. Download completed
+      // 2. Download progressed by at least 1.0%
+      // 3. Or at least 1.5 seconds have passed
+      const shouldUpdate = isCompleted || (percentage - lastPercent >= 1.0) || (now - lastTime >= 1500);
+
+      if (shouldUpdate) {
+        packLastUpdatePercent.set(packName, percentage);
+        packLastUpdateTime.set(packName, now);
+
+        const lastSize = packLastSize.get(packName) || 0;
+        const currentSize = status.completedResourceSize || 0;
+        const deltaBytes = currentSize - lastSize;
+
+        if (deltaBytes > 0) {
+          packLastSize.set(packName, currentSize);
+          addTelemetryEvent({
+            type: 'map_tile_pack',
+            url: `map-pack://${packName}`,
+            caller: 'Offline Maps',
+            reqSize: 0,
+            resSize: deltaBytes,
+            status: 200,
+            duration: 0
+          });
+        }
+
+        updateMapMetadata(packName, {
+          progress: percentage,
+          size: currentSize
         });
+
+        if (progressCallbacks[packName]) {
+          progressCallbacks[packName]({
+            name: packName,
+            percentage,
+            completedSize: currentSize,
+            completedResourceCount: status.completedResourceCount,
+            requiredResourceCount: status.requiredResourceCount
+          });
+        }
       }
     },
     (error) => {
@@ -219,6 +259,7 @@ const deletePack = async (name) => {
     }
 
     pendingDownloads = pendingDownloads.filter((downloadName) => downloadName !== name);
+    packLastSize.delete(name);
     savePendingDownloads();
   } catch (error) {
     console.error(`Error deleting pack ${name}:`, error);
@@ -373,8 +414,10 @@ const cancelPackDownload = async (name) => {
     } catch (e) {}
 
     pendingDownloads = pendingDownloads.filter((downloadName) => downloadName !== name);
+    packLastSize.delete(name);
     savePendingDownloads();
 
+
     updateMapMetadata(name, {
       status: 'invalid',
       progress: 0,

+ 3 - 1
src/types/navigation.ts

@@ -93,5 +93,7 @@ export enum NAVIGATION_PAGES {
   IN_APP_EVENTS_TAB = 'Events',
   CREATE_SHARED_TRIP = 'inAppCreateSharedTrip',
   EDIT_NM_DATA = 'inAppEditNmData',
-  REGIONS_VISITED = 'inAppRegionsVisited'
+  REGIONS_VISITED = 'inAppRegionsVisited',
+  DATA_REPORT = 'dataReport'
 }
+

+ 73 - 0
src/utils/fileSystem.ts

@@ -0,0 +1,73 @@
+import * as OriginalFileSystem from 'expo-file-system/legacy';
+import { addTelemetryEvent } from './telemetry';
+
+const wrappedDownloadAsync = async function (
+  url: string,
+  fileUri: string,
+  options?: any
+): Promise<any> {
+  const start = Date.now();
+  try {
+    const result = await OriginalFileSystem.downloadAsync(url, fileUri, options);
+    const duration = Date.now() - start;
+
+    let fileSize = 0;
+    try {
+      const info = await OriginalFileSystem.getInfoAsync(fileUri);
+      if (info.exists) {
+        fileSize = info.size || 0;
+      }
+    } catch {}
+
+    let caller = 'File Download';
+    const lowerUrl = url.toLowerCase();
+    if (lowerUrl.includes('.db')) {
+      caller = 'Database Update (.db)';
+    } else if (lowerUrl.includes('/flags/')) {
+      caller = 'Flags Download';
+    } else if (lowerUrl.includes('map')) {
+      caller = 'Map Details';
+    } else if (lowerUrl.includes('chat') || lowerUrl.includes('message')) {
+      caller = 'Chat & Messages';
+    }
+
+    addTelemetryEvent({
+      type: 'file_download',
+      url,
+      caller,
+      reqSize: 0,
+      resSize: fileSize,
+      status: 200,
+      duration
+    });
+
+    return result;
+  } catch (error: any) {
+    const duration = Date.now() - start;
+    let caller = 'File Download (Failed)';
+    const lowerUrl = url.toLowerCase();
+    if (lowerUrl.includes('.db')) {
+      caller = 'Database Update (Failed)';
+    }
+
+    addTelemetryEvent({
+      type: 'file_download',
+      url,
+      caller,
+      reqSize: 0,
+      resSize: 0,
+      status: error.status || 500,
+      duration
+    });
+    throw error;
+  }
+};
+
+export const FileSystem = new Proxy(OriginalFileSystem, {
+  get(target, prop, receiver) {
+    if (prop === 'downloadAsync') {
+      return wrappedDownloadAsync;
+    }
+    return Reflect.get(target, prop, receiver);
+  }
+}) as typeof OriginalFileSystem;

+ 4 - 0
src/utils/index.ts

@@ -1,2 +1,6 @@
 export * from './request';
 export * from './responsive-font';
+export * from './telemetry';
+export * from './fileSystem';
+
+

+ 5 - 0
src/utils/request.ts

@@ -3,12 +3,17 @@ import { API_URL, APP_VERSION } from '../constants';
 import { Platform } from 'react-native';
 import { showBanner } from './bannerUtils';
 import { useErrorStore } from 'src/stores/errorStore';
+import { attachTelemetryInterceptors } from './telemetry';
 
 export const request = axios.create({
   baseURL: API_URL,
   timeout: 10000
 });
 
+attachTelemetryInterceptors(request);
+attachTelemetryInterceptors(axios);
+
+
 export const setupInterceptors = ({
   showError
 }: {

+ 299 - 0
src/utils/telemetry.ts

@@ -0,0 +1,299 @@
+import { Platform } from 'react-native';
+import { MMKV } from 'react-native-mmkv';
+import * as FileSystem from 'expo-file-system/legacy';
+import * as MapLibreRN from '@maplibre/maplibre-react-native';
+
+const telemetryStorage = new MMKV({ id: 'telemetry_storage_v1' });
+const EVENTS_KEY = 'events';
+
+export const APP_OPEN_TIME = Date.now();
+
+export interface TelemetryEvent {
+  id: string;
+  type: 'api_request' | 'file_download' | 'map_tile_pack' | 'other';
+  timestamp: number;
+  url: string;
+  method?: string;
+  caller: string;
+  reqSize: number;
+  resSize: number;
+  status: number;
+  duration: number;
+}
+
+export function sanitizeUrlForDisplay(url: string): string {
+  if (!url) return '';
+  let clean = url.split('?')[0];
+  
+  clean = clean.split('/').map(segment => {
+    if (segment && !isNaN(Number(segment))) {
+      return ':id';
+    }
+
+    const dotIndex = segment.lastIndexOf('.');
+    if (dotIndex !== -1) {
+      const name = segment.substring(0, dotIndex);
+      const ext = segment.substring(dotIndex);
+      
+      const cleanName = name
+        .replace('_visited', '')
+        .replace('visited', '')
+        .replace('v', '');
+        
+      const isNumeric = cleanName !== '' && !isNaN(Number(cleanName));
+      if (isNumeric) {
+        const isVisited = name.includes('_visited') || name.includes('visited') || name.endsWith('v');
+        return isVisited ? `:id_visited${ext}` : `:id${ext}`;
+      }
+    }
+    return segment;
+  }).join('/');
+  
+  return clean;
+}
+
+export function inferCallerFromUrl(url: string): string {
+  if (!url) return 'Other';
+  
+  const lowerUrl = url.toLowerCase();
+  
+  if (lowerUrl.includes('.db')) {
+    return 'Databases (.db)';
+  }
+  if (lowerUrl.includes('/flags/') || lowerUrl.includes('/flags_new/')) {
+    return 'Flags';
+  }
+  if (lowerUrl.includes('/series_new2_small/')) {
+    return 'Series Icons';
+  }
+  if (lowerUrl.includes('map-pack://') || lowerUrl.includes('offline-map')) {
+    return 'Offline Maps';
+  }
+  if (lowerUrl.endsWith('.png') || lowerUrl.endsWith('.jpg') || lowerUrl.endsWith('.jpeg') || lowerUrl.endsWith('.svg') || lowerUrl.endsWith('.gif') || lowerUrl.endsWith('.webp')) {
+    return 'Downloads';
+  }
+  
+  try {
+    const cleanUrl = url.split('?')[0];
+    const parts = cleanUrl.split('/');
+    
+    const apiIndex = parts.findIndex(p => p.toLowerCase() === 'api');
+    if (apiIndex !== -1 && apiIndex + 1 < parts.length) {
+      const moduleName = parts[apiIndex + 1].toLowerCase();
+      return moduleName.charAt(0) + moduleName.slice(1);
+    }
+    
+    const cleanParts = parts.filter(p => p && !p.includes(':') && !p.includes('.'));
+    if (cleanParts.length > 0) {
+      const moduleName = cleanParts[0].toLowerCase();
+      return moduleName.charAt(0) + moduleName.slice(1);
+    }
+  } catch {}
+
+  return 'Other';
+}
+
+export function getTelemetryEvents(): TelemetryEvent[] {
+  try {
+    const data = telemetryStorage.getString(EVENTS_KEY);
+    return data ? JSON.parse(data) : [];
+  } catch (error) {
+    console.error('Failed to parse telemetry events:', error);
+    return [];
+  }
+}
+
+export function addTelemetryEvent(event: Omit<TelemetryEvent, 'timestamp' | 'id'>) {
+  try {
+    const events = getTelemetryEvents();
+    
+    const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
+    const cutoff = Date.now() - ONE_WEEK_MS;
+    let cleanEvents = events.filter(e => e.timestamp >= cutoff);
+
+    const latest = cleanEvents[0];
+    if (latest && latest.url === event.url && (event.type === 'map_tile_pack' || event.type === 'file_download')) {
+      latest.resSize += event.resSize;
+      latest.reqSize += event.reqSize;
+      latest.duration += event.duration;
+      latest.timestamp = Date.now();
+    } else {
+      const newEvent: TelemetryEvent = {
+        ...event,
+        id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
+        timestamp: Date.now()
+      };
+      cleanEvents.unshift(newEvent);
+    }
+    
+    telemetryStorage.set(EVENTS_KEY, JSON.stringify(cleanEvents));
+  } catch (error) {
+    console.error('Failed to save telemetry event:', error);
+  }
+}
+
+export function clearTelemetryEvents() {
+  try {
+    telemetryStorage.delete(EVENTS_KEY);
+  } catch (error) {
+    console.error('Failed to clear telemetry events:', error);
+  }
+}
+
+export async function getAmbientCacheSize(): Promise<number> {
+  try {
+    const packs = await MapLibreRN.OfflineManager.getPacks();
+    let totalSize = 0;
+    for (const pack of packs) {
+      try {
+        const status = await pack.status();
+        if (status) {
+          totalSize += status.completedResourceSize || 0;
+        }
+      } catch {}
+    }
+    return totalSize;
+  } catch (error) {
+    console.warn('Failed to get MapLibre offline packs size:', error);
+    return 0;
+  }
+}
+
+export function getTelemetrySummary() {
+  const events = getTelemetryEvents();
+  let totalDownloaded = 0;
+  let totalUploaded = 0;
+  const callerBreakdown: Record<string, { count: number; downloaded: number; duration: number }> = {};
+  const typeBreakdown: Record<string, { count: number; downloaded: number }> = {};
+
+  for (const event of events) {
+    totalDownloaded += event.resSize;
+    totalUploaded += event.reqSize;
+
+    const caller = event.caller || 'Other';
+    if (!callerBreakdown[caller]) {
+      callerBreakdown[caller] = { count: 0, downloaded: 0, duration: 0 };
+    }
+    callerBreakdown[caller].count += 1;
+    callerBreakdown[caller].downloaded += event.resSize;
+    callerBreakdown[caller].duration += event.duration;
+
+    const type = event.type;
+    if (!typeBreakdown[type]) {
+      typeBreakdown[type] = { count: 0, downloaded: 0 };
+    }
+    typeBreakdown[type].count += 1;
+    typeBreakdown[type].downloaded += event.resSize;
+  }
+
+  return {
+    totalEvents: events.length,
+    totalDownloaded,
+    totalUploaded,
+    callerBreakdown: Object.entries(callerBreakdown)
+      .map(([name, data]) => ({
+        name,
+        count: data.count,
+        downloaded: data.downloaded,
+        avgDuration: data.count > 0 ? Math.round(data.duration / data.count) : 0
+      }))
+      .sort((a, b) => b.downloaded - a.downloaded),
+    typeBreakdown: Object.entries(typeBreakdown)
+      .map(([name, data]) => ({
+        name,
+        count: data.count,
+        downloaded: data.downloaded
+      }))
+      .sort((a, b) => b.downloaded - a.downloaded)
+  };
+}
+
+export function attachTelemetryInterceptors(axiosInstance: any) {
+  axiosInstance.interceptors.request.use(
+    (config: any) => {
+      try {
+        config.metadata = { startTime: Date.now() };
+      } catch {}
+      return config;
+    },
+    (error: any) => {
+      return Promise.reject(error);
+    }
+  );
+
+  axiosInstance.interceptors.response.use(
+    (response: any) => {
+      try {
+        const startTime = response.config?.metadata?.startTime || Date.now();
+        const duration = Date.now() - startTime;
+        
+        let reqSize = 0;
+        if (response.config?.data) {
+          try {
+            reqSize = typeof response.config.data === 'string' 
+              ? response.config.data.length 
+              : JSON.stringify(response.config.data).length;
+          } catch {}
+        }
+        
+        let resSize = 0;
+        if (response.data) {
+          try {
+            resSize = typeof response.data === 'string'
+              ? response.data.length
+              : JSON.stringify(response.data).length;
+          } catch {}
+        }
+
+        const url = response.config?.url || '';
+        const method = response.config?.method?.toUpperCase() || 'GET';
+        const caller = inferCallerFromUrl(url);
+
+        addTelemetryEvent({
+          type: 'api_request',
+          url,
+          method,
+          caller,
+          reqSize,
+          resSize,
+          status: response.status || 200,
+          duration
+        });
+      } catch (error) {
+        console.error('Failed to log response telemetry:', error);
+      }
+      return response;
+    },
+    (error: any) => {
+      try {
+        const startTime = error.config?.metadata?.startTime || Date.now();
+        const duration = Date.now() - startTime;
+
+        let reqSize = 0;
+        if (error.config?.data) {
+          try {
+            reqSize = typeof error.config.data === 'string'
+              ? error.config.data.length
+              : JSON.stringify(error.config.data).length;
+          } catch {}
+        }
+
+        const url = error.config?.url || '';
+        const method = error.config?.method?.toUpperCase() || 'GET';
+        const caller = inferCallerFromUrl(url);
+
+        addTelemetryEvent({
+          type: 'api_request',
+          url,
+          method,
+          caller,
+          reqSize,
+          resSize: 0,
+          status: error.response?.status || 0,
+          duration
+        });
+      } catch {}
+      return Promise.reject(error);
+    }
+  );
+}