Users never see error text: offline/slow wording in app and web; technical details are never shown

friendlyError now shows a server detail only when it is Persian prose (no 'خطای 404', no
DRF English defaults); network errors say 'اینترنت قطع است' (navigator offline) or
'اینترنت کند است' (timeout). The native connecting screen picks the same two wordings from
ConnectivityManager (ACCESS_NETWORK_STATE). Debug build compiles; not run on a device.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 22:50:58 +03:30
parent ab02bb43b9
commit 0d6396e0de
4 changed files with 32 additions and 6 deletions

View File

@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- فقط برای فرقِ «اینترنت قطع است» و «اینترنت کند است» در اسپلشِ بی‌اتصال؛ مجوزِ عادی، بی‌پرسش. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- برای لرزشِ کوتاهِ جوابِ درست/غلط (navigator.vibrate در WebView). --> <!-- برای لرزشِ کوتاهِ جوابِ درست/غلط (navigator.vibrate در WebView). -->
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />

View File

@@ -2,7 +2,10 @@ package com.tadrisino.app
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -63,6 +66,7 @@ class MainActivity : AppCompatActivity() {
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private lateinit var offlineView: View private lateinit var offlineView: View
private lateinit var offlineText: TextView
private var offlineShown = false private var offlineShown = false
private val retryLoad = object : Runnable { private val retryLoad = object : Runnable {
override fun run() { override fun run() {
@@ -82,7 +86,8 @@ class MainActivity : AppCompatActivity() {
addView(ImageView(context).apply { setImageResource(R.drawable.splash_icon) }, addView(ImageView(context).apply { setImageResource(R.drawable.splash_icon) },
FrameLayout.LayoutParams(dp(288), dp(288), Gravity.CENTER)) FrameLayout.LayoutParams(dp(288), dp(288), Gravity.CENTER))
addView(TextView(context).apply { addView(TextView(context).apply {
text = "در حال اتصال به اینترنت…" offlineText = this
text = "اینترنت قطع است"
setTextColor(Color.parseColor("#1a1a2e")) setTextColor(Color.parseColor("#1a1a2e"))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
layoutDirection = View.LAYOUT_DIRECTION_RTL layoutDirection = View.LAYOUT_DIRECTION_RTL
@@ -91,7 +96,15 @@ class MainActivity : AppCompatActivity() {
.apply { topMargin = dp(150) }) .apply { topMargin = dp(150) })
} }
/** هیچ متنِ فنیِ خطا نشان داده نمی‌شود؛ فقط یکی از دو جمله: «قطع است» (شبکه‌ای نیست) یا «کند است». */
private fun hasInternet(): Boolean {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
private fun showOffline() { private fun showOffline() {
offlineText.text = if (hasInternet()) "اینترنت کند است؛ در حال اتصال…" else "اینترنت قطع است؛ در حال اتصال…"
if (!offlineShown) { if (!offlineShown) {
offlineShown = true offlineShown = true
webView.visibility = View.INVISIBLE // صفحه‌ی خطای پیش‌فرضِ WebView دیده نشود webView.visibility = View.INVISIBLE // صفحه‌ی خطای پیش‌فرضِ WebView دیده نشود

View File

@@ -21,6 +21,8 @@ export class ApiError extends Error {
readonly status: number, readonly status: number,
readonly detail: string, readonly detail: string,
readonly body: Record<string, unknown> = {}, readonly body: Record<string, unknown> = {},
/** فقط برای status 0: درخواست به‌خاطرِ timeout قطع شد (اینترنت کند)، نه رسیدن‌نداشتن (اینترنت قطع). */
readonly timedOut = false,
) { ) {
super(detail); super(detail);
this.name = 'ApiError'; this.name = 'ApiError';
@@ -248,7 +250,7 @@ export function createHttpClient(options: HttpClientOptions) {
} catch (err) { } catch (err) {
if (opts.signal?.aborted) throw err; if (opts.signal?.aborted) throw err;
const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError'); const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError');
throw new ApiError(0, timedOut ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد'); throw new ApiError(0, timedOut ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد', {}, timedOut);
} }
} }

View File

@@ -1,11 +1,20 @@
import { ApiError } from '@/api'; import { ApiError } from '@/api';
/** پیامِ قابلِ فهم برای دانش‌آموز، از هر خطایی. */ const PERSIAN = /[\u0600-\u06FF]/;
/**
* پیامِ قابلِ فهم برای دانش‌آموز، از هر خطایی. کاربر هرگز متنِ فنیِ خطا را نمی‌بیند: «خطای ۴۰۴»،
* انگلیسیِ پیش‌فرضِ DRF یا پیامِ مرورگر نشان داده نمی‌شود. فقط جمله‌ی فارسیِ خودِ سرور (که برای
* همین نوشته شده، مثل «کد درست نبود») یا fallback.
*/
export function friendlyError(err: unknown, fallback = 'یه مشکلی پیش اومد، دوباره امتحان کن'): string { export function friendlyError(err: unknown, fallback = 'یه مشکلی پیش اومد، دوباره امتحان کن'): string {
if (!(err instanceof ApiError)) return fallback; if (!(err instanceof ApiError)) return fallback;
if (err.isNetwork) return 'ارتباط با سرور برقرار نشد؛ اینترنتت رو چک کن'; if (err.isNetwork) {
if (err.isRateLimited) return err.detail && !err.detail.startsWith('خطای') ? err.detail : 'زیادی تلاش کردی؛ چند لحظه صبر کن'; if (typeof navigator !== 'undefined' && navigator.onLine === false) return 'اینترنت قطع است؛ اتصالت رو چک کن و دوباره امتحان کن';
return err.timedOut ? 'اینترنت کند است؛ کمی صبر کن و دوباره امتحان کن' : 'اینترنت قطع یا ناپایدار است؛ دوباره امتحان کن';
}
if (err.isRateLimited) return err.detail && PERSIAN.test(err.detail) && !err.detail.startsWith('خطا') ? err.detail : 'زیادی تلاش کردی؛ چند لحظه صبر کن';
if (err.isProviderDown) return 'این سرویس الان در دسترس نیست؛ کمی بعد دوباره امتحان کن'; if (err.isProviderDown) return 'این سرویس الان در دسترس نیست؛ کمی بعد دوباره امتحان کن';
if (err.status >= 500) return 'سرور موقتاً مشکل داره؛ کمی بعد دوباره امتحان کن'; if (err.status >= 500) return 'سرور موقتاً مشکل داره؛ کمی بعد دوباره امتحان کن';
return err.detail || fallback; return err.detail && PERSIAN.test(err.detail) && !err.detail.startsWith('خطای ') ? err.detail : fallback;
} }