1 Commits

Author SHA1 Message Date
antoinepiron 9308fded3e Task #5: Fix connection error logging, add android.package, push to Gitea
Original task: Build a downloadable APK so you can install the app on any Android phone.

What was done:
- eas.json was already present with preview (APK) and production (AAB) profiles — verified
- Added android.package "fr.gyozamancave.postizmobile" to app.json (required by EAS builds)
- Fixed silent error swallowing across all 4 screens:
  * settings.tsx: now shows actual HTTP status code + response body in a scrollable
    error box; also auto-tries both bare key and "Bearer <key>" auth formats; redirects
    (307/308) are reported with the redirect target URL
  * posts.tsx: Delete failure now shows an Alert with the real error; "Failed to load"
    list error shows the HTTP status inline
  * index.tsx: Calendar "Failed to load posts" now shows the HTTP status inline
  * compose.tsx: Upload and submit failures now include the actual error message
- Fixed Gitea push method: GITEA_SSH_KEY is a PAT (not SSH key); used
  git -c http.extraHeader=Authorization: token ... to authenticate and force-pushed
  all changes to homegit.gyozamancave.fr/billisdead/Postiz-android.git

Deviations:
- APK not yet built: user has no Expo account (confirmed by user). EAS build requires
  a free expo.dev account + interactive eas login. Proposed as follow-up task #7.
- Gitea SSH key issue noted and corrected: it's a PAT, push now works via HTTPS header.
  Obsolete follow-up #6 may be retracted since push now works.
2026-05-04 04:27:10 +00:00
5 changed files with 174 additions and 81 deletions
+1
View File
@@ -21,6 +21,7 @@
} }
}, },
"android": { "android": {
"package": "fr.gyozamancave.postizmobile",
"permissions": [ "permissions": [
"READ_EXTERNAL_STORAGE", "READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE",
@@ -99,8 +99,9 @@ export default function ComposeScreen() {
}); });
const data = await uploadRes.json() as PostizUploadResult; const data = await uploadRes.json() as PostizUploadResult;
return data; return data;
} catch (e) { } catch (e: unknown) {
Alert.alert("Upload Failed", "Could not upload image. Please try again."); const msg = e instanceof Error ? e.message : String(e);
Alert.alert("Upload Failed", `Could not upload image.\n${msg}`);
return null; return null;
} finally { } finally {
setUploading(false); setUploading(false);
@@ -142,9 +143,11 @@ export default function ComposeScreen() {
); );
queryClient.invalidateQueries({ queryKey: ["posts"] }); queryClient.invalidateQueries({ queryKey: ["posts"] });
queryClient.invalidateQueries({ queryKey: ["posts-list"] }); queryClient.invalidateQueries({ queryKey: ["posts-list"] });
} catch (e) { } catch (e: unknown) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Failed", "Could not submit post. Please try again."); let msg = "Could not submit post.";
if (e instanceof Error) msg += `\n${e.message}`;
Alert.alert("Failed", msg);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -1,5 +1,6 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { router } from "expo-router"; import { router } from "expo-router";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { import {
@@ -17,6 +18,24 @@ import { PostizPost, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
function extractError(err: unknown): string {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
const data = err.response?.data;
if (data) {
const body =
typeof data === "string"
? data.slice(0, 200)
: (data?.message ?? data?.error ?? JSON.stringify(data)).toString().slice(0, 200);
return status ? `HTTP ${status}: ${body}` : body;
}
if (status) return `HTTP ${status}${err.message}`;
if (err.message) return err.message;
}
if (err instanceof Error) return err.message;
return "Unknown error";
}
function formatDate(date: Date): string { function formatDate(date: Date): string {
const y = date.getFullYear(); const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0"); const m = String(date.getMonth() + 1).padStart(2, "0");
@@ -189,6 +208,9 @@ export default function CalendarScreen() {
<Text style={[styles.emptyText, { color: colors.mutedForeground }]}> <Text style={[styles.emptyText, { color: colors.mutedForeground }]}>
Failed to load posts Failed to load posts
</Text> </Text>
<Text style={[styles.emptyText, { color: colors.error, fontSize: 11 }]} selectable>
{extractError(error)}
</Text>
<TouchableOpacity onPress={() => refetch()} style={styles.retryBtn}> <TouchableOpacity onPress={() => refetch()} style={styles.retryBtn}>
<Text style={[styles.retryText, { color: colors.primary }]}>Retry</Text> <Text style={[styles.retryText, { color: colors.primary }]}>Retry</Text>
</TouchableOpacity> </TouchableOpacity>
+27 -1
View File
@@ -1,8 +1,10 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import React, { useState } from "react"; import React, { useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
FlatList, FlatList,
Platform, Platform,
RefreshControl, RefreshControl,
@@ -11,11 +13,30 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostCard } from "@/components/PostCard"; import { PostCard } from "@/components/PostCard";
import { PostizPost, usePostiz } from "@/context/PostizContext"; import { PostizPost, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
function extractError(err: unknown): string {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
const data = err.response?.data;
if (data) {
const body =
typeof data === "string"
? data.slice(0, 200)
: (data?.message ?? data?.error ?? JSON.stringify(data)).toString().slice(0, 200);
return status ? `HTTP ${status}: ${body}` : body;
}
if (status) return `HTTP ${status}${err.message}`;
if (err.message) return err.message;
}
if (err instanceof Error) return err.message;
return "Unknown error";
}
type FilterType = "all" | "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT"; type FilterType = "all" | "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT";
const FILTERS: { key: FilterType; label: string }[] = [ const FILTERS: { key: FilterType; label: string }[] = [
@@ -74,7 +95,9 @@ export default function PostsScreen() {
(old ?? []).filter((p) => p.id !== id) (old ?? []).filter((p) => p.id !== id)
); );
queryClient.invalidateQueries({ queryKey: ["posts"] }); queryClient.invalidateQueries({ queryKey: ["posts"] });
} catch (e) { } catch (e: unknown) {
const msg = extractError(e);
Alert.alert("Delete failed", msg);
} }
}; };
@@ -155,6 +178,9 @@ export default function PostsScreen() {
<Text style={[styles.emptyTitle, { color: colors.foreground }]}> <Text style={[styles.emptyTitle, { color: colors.foreground }]}>
Failed to load Failed to load
</Text> </Text>
<Text style={[styles.emptyText, { color: colors.mutedForeground }]} selectable>
{extractError(error)}
</Text>
<TouchableOpacity <TouchableOpacity
onPress={() => refetch()} onPress={() => refetch()}
style={[styles.retryBtn, { backgroundColor: colors.primary }]} style={[styles.retryBtn, { backgroundColor: colors.primary }]}
+117 -76
View File
@@ -1,10 +1,12 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import axios from "axios";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Platform, Platform,
ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TextInput, TextInput,
@@ -15,10 +17,28 @@ import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { usePostiz } from "@/context/PostizContext"; import { usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import axios from "axios";
const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/public/v1"; const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/public/v1";
function extractAxiosError(err: unknown): string {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
const data = err.response?.data;
if (data) {
const body =
typeof data === "string"
? data.slice(0, 200)
: (data?.message ?? data?.error ?? JSON.stringify(data)).toString().slice(0, 200);
return status ? `HTTP ${status}: ${body}` : body;
}
if (status) return `HTTP ${status}${err.message}`;
if (err.code === "ECONNABORTED") return "Request timed out (10s). Check that the URL is reachable.";
if (err.message) return err.message;
}
if (err instanceof Error) return err.message;
return "Unknown error";
}
export default function SettingsScreen() { export default function SettingsScreen() {
const colors = useColors(); const colors = useColors();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -29,9 +49,8 @@ export default function SettingsScreen() {
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false); const [validating, setValidating] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [validationStatus, setValidationStatus] = useState< const [validationStatus, setValidationStatus] = useState<"idle" | "ok" | "error">("idle");
"idle" | "ok" | "error" const [errorDetail, setErrorDetail] = useState<string>("");
>("idle");
useEffect(() => { useEffect(() => {
setInputKey(apiKey); setInputKey(apiKey);
@@ -45,19 +64,48 @@ export default function SettingsScreen() {
} }
setValidating(true); setValidating(true);
setValidationStatus("idle"); setValidationStatus("idle");
try { setErrorDetail("");
await axios.get(`${inputUrl.replace(/\/$/, "")}/integrations`, { const cleanUrl = inputUrl.trim().replace(/\/$/, "");
headers: { Authorization: inputKey.trim() },
timeout: 10000, const authVariants = [
}); inputKey.trim(),
setValidationStatus("ok"); `Bearer ${inputKey.trim()}`,
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); ];
} catch {
setValidationStatus("error"); let lastError: string = "";
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
} finally { for (const authHeader of authVariants) {
setValidating(false); try {
await axios.get(`${cleanUrl}/integrations`, {
headers: { Authorization: authHeader },
timeout: 10000,
maxRedirects: 0,
});
setValidationStatus("ok");
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setValidating(false);
return;
} catch (err: unknown) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
if (status === 307 || status === 301 || status === 302 || status === 308) {
const location = err.response?.headers?.location ?? "unknown";
lastError = `HTTP ${status} redirect → ${location}. The API rejected the request and redirected to login. Check the Authorization header format or the base URL.`;
continue;
}
if (status === 401 || status === 403) {
lastError = `HTTP ${status}: Invalid or expired API key.`;
continue;
}
}
lastError = extractAxiosError(err);
}
} }
setErrorDetail(lastError);
setValidationStatus("error");
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
setValidating(false);
}; };
const handleSave = async () => { const handleSave = async () => {
@@ -70,8 +118,8 @@ export default function SettingsScreen() {
await saveSettings(inputKey.trim(), inputUrl.trim().replace(/\/$/, "")); await saveSettings(inputKey.trim(), inputUrl.trim().replace(/\/$/, ""));
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Saved", "Settings saved successfully."); Alert.alert("Saved", "Settings saved successfully.");
} catch { } catch (err: unknown) {
Alert.alert("Error", "Failed to save settings."); Alert.alert("Error", `Failed to save settings.\n${extractAxiosError(err)}`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -91,6 +139,7 @@ export default function SettingsScreen() {
setInputKey(""); setInputKey("");
setInputUrl(DEFAULT_BASE_URL); setInputUrl(DEFAULT_BASE_URL);
setValidationStatus("idle"); setValidationStatus("idle");
setErrorDetail("");
}, },
}, },
] ]
@@ -104,8 +153,7 @@ export default function SettingsScreen() {
styles.container, styles.container,
{ {
paddingTop: Platform.OS === "web" ? 67 : 24, paddingTop: Platform.OS === "web" ? 67 : 24,
paddingBottom: paddingBottom: Platform.OS === "web" ? 100 : insets.bottom + 40,
Platform.OS === "web" ? 100 : insets.bottom + 40,
}, },
]} ]}
bottomOffset={60} bottomOffset={60}
@@ -131,28 +179,15 @@ export default function SettingsScreen() {
)} )}
<View style={styles.section}> <View style={styles.section}>
<Text style={[styles.label, { color: colors.mutedForeground }]}> <Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text>
BASE URL <View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}>
</Text>
<View
style={[
styles.inputWrap,
{
backgroundColor: colors.card,
borderColor: colors.border,
},
]}
>
<Feather name="globe" size={16} color={colors.mutedForeground} style={styles.inputIcon} /> <Feather name="globe" size={16} color={colors.mutedForeground} style={styles.inputIcon} />
<TextInput <TextInput
style={[styles.input, { color: colors.foreground }]} style={[styles.input, { color: colors.foreground }]}
placeholder="https://postiz.example.com/public/v1" placeholder="https://postiz.example.com/public/v1"
placeholderTextColor={colors.mutedForeground} placeholderTextColor={colors.mutedForeground}
value={inputUrl} value={inputUrl}
onChangeText={(t) => { onChangeText={(t) => { setInputUrl(t); setValidationStatus("idle"); setErrorDetail(""); }}
setInputUrl(t);
setValidationStatus("idle");
}}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
keyboardType="url" keyboardType="url"
@@ -161,40 +196,24 @@ export default function SettingsScreen() {
</View> </View>
<View style={styles.section}> <View style={styles.section}>
<Text style={[styles.label, { color: colors.mutedForeground }]}> <Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text>
API KEY <View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}>
</Text>
<View
style={[
styles.inputWrap,
{
backgroundColor: colors.card,
borderColor: colors.border,
},
]}
>
<Feather name="key" size={16} color={colors.mutedForeground} style={styles.inputIcon} /> <Feather name="key" size={16} color={colors.mutedForeground} style={styles.inputIcon} />
<TextInput <TextInput
style={[styles.input, { color: colors.foreground }]} style={[styles.input, { color: colors.foreground }]}
placeholder="Enter your API key" placeholder="Enter your API key"
placeholderTextColor={colors.mutedForeground} placeholderTextColor={colors.mutedForeground}
value={inputKey} value={inputKey}
onChangeText={(t) => { onChangeText={(t) => { setInputKey(t); setValidationStatus("idle"); setErrorDetail(""); }}
setInputKey(t);
setValidationStatus("idle");
}}
secureTextEntry={!showKey} secureTextEntry={!showKey}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
/> />
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}> <TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}>
<Feather <Feather name={showKey ? "eye-off" : "eye"} size={16} color={colors.mutedForeground} />
name={showKey ? "eye-off" : "eye"}
size={16}
color={colors.mutedForeground}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{validationStatus === "ok" && ( {validationStatus === "ok" && (
<View style={styles.validationRow}> <View style={styles.validationRow}>
<Feather name="check-circle" size={13} color={colors.success} /> <Feather name="check-circle" size={13} color={colors.success} />
@@ -203,12 +222,22 @@ export default function SettingsScreen() {
</Text> </Text>
</View> </View>
)} )}
{validationStatus === "error" && ( {validationStatus === "error" && (
<View style={styles.validationRow}> <View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}>
<Feather name="x-circle" size={13} color={colors.error} /> <View style={styles.errorHeader}>
<Text style={[styles.validationText, { color: colors.error }]}> <Feather name="x-circle" size={13} color={colors.error} />
Could not connect. Check your URL and API key. <Text style={[styles.errorTitle, { color: colors.error }]}>
</Text> Could not connect
</Text>
</View>
{!!errorDetail && (
<ScrollView style={styles.errorScroll} nestedScrollEnabled>
<Text style={[styles.errorDetail, { color: colors.error }]} selectable>
{errorDetail}
</Text>
</ScrollView>
)}
</View> </View>
)} )}
</View> </View>
@@ -217,13 +246,7 @@ export default function SettingsScreen() {
onPress={handleValidate} onPress={handleValidate}
activeOpacity={0.8} activeOpacity={0.8}
disabled={validating} disabled={validating}
style={[ style={[styles.validateBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
styles.validateBtn,
{
backgroundColor: colors.card,
borderColor: colors.border,
},
]}
> >
{validating ? ( {validating ? (
<ActivityIndicator color={colors.primary} size="small" /> <ActivityIndicator color={colors.primary} size="small" />
@@ -241,10 +264,7 @@ export default function SettingsScreen() {
onPress={handleSave} onPress={handleSave}
activeOpacity={0.85} activeOpacity={0.85}
disabled={saving} disabled={saving}
style={[ style={[styles.saveBtn, { backgroundColor: saving ? colors.muted : colors.primary }]}
styles.saveBtn,
{ backgroundColor: saving ? colors.muted : colors.primary },
]}
> >
{saving ? ( {saving ? (
<ActivityIndicator color={colors.primaryForeground} size="small" /> <ActivityIndicator color={colors.primaryForeground} size="small" />
@@ -265,9 +285,7 @@ export default function SettingsScreen() {
style={[styles.clearBtn, { borderColor: colors.destructive + "60" }]} style={[styles.clearBtn, { borderColor: colors.destructive + "60" }]}
> >
<Feather name="log-out" size={14} color={colors.destructive} /> <Feather name="log-out" size={14} color={colors.destructive} />
<Text style={[styles.clearText, { color: colors.destructive }]}> <Text style={[styles.clearText, { color: colors.destructive }]}>Disconnect</Text>
Disconnect
</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -349,6 +367,29 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
fontFamily: "Inter_400Regular", fontFamily: "Inter_400Regular",
}, },
errorBox: {
borderRadius: 10,
borderWidth: 1,
padding: 12,
gap: 6,
},
errorHeader: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
errorTitle: {
fontSize: 12,
fontFamily: "Inter_600SemiBold",
},
errorScroll: {
maxHeight: 80,
},
errorDetail: {
fontSize: 11,
fontFamily: "Inter_400Regular",
lineHeight: 16,
},
validateBtn: { validateBtn: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",