mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 04:44:35 +03:00
feat: add automatic hourly resolver health-check and UI toggle
This commit is contained in:
@@ -79,12 +79,16 @@ class MainActivity : ComponentActivity() {
|
||||
private fun registerBackHandler() {
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack()
|
||||
} else {
|
||||
// No WebView history to go back to — move app to background
|
||||
// instead of finishing the activity (keeps the service alive).
|
||||
moveTaskToBack(true)
|
||||
// Check if the chat view is open (mobile nav). If yes, go back
|
||||
// to the channel list. If already on the channel list, minimize.
|
||||
webView.evaluateJavascript(
|
||||
"(document.getElementById('app').classList.contains('chat-open')).toString()"
|
||||
) { result ->
|
||||
if (result.trim('"') == "true") {
|
||||
webView.goBack()
|
||||
} else {
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -100,22 +104,43 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private var batteryOptRequested = false
|
||||
|
||||
@Suppress("BatteryLife")
|
||||
private fun requestDisableBatteryOptimization() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
}
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
// Some devices don't support this intent
|
||||
if (pm.isIgnoringBatteryOptimizations(packageName)) return
|
||||
val prefs = getSharedPreferences(ThefeedService.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean(PREF_BATTERY_OPT_DECLINED, false)) return
|
||||
batteryOptRequested = true
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
}
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
batteryOptRequested = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (batteryOptRequested) {
|
||||
batteryOptRequested = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||
// User declined — save preference so we don't ask again
|
||||
getSharedPreferences(ThefeedService.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(PREF_BATTERY_OPT_DECLINED, true).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startThefeedService() {
|
||||
val intent = Intent(this, ThefeedService::class.java)
|
||||
@@ -265,6 +290,7 @@ class MainActivity : ComponentActivity() {
|
||||
private const val PROBE_INTERVAL_MS = 1000L // 1s between probes → up to 30s total
|
||||
private const val PROBE_TIMEOUT_MS = 1000L // 1s HTTP connect timeout per probe
|
||||
private const val RETRY_DELAY_MS = 2000L // delay before restarting probe cycle on error
|
||||
private const val PREF_BATTERY_OPT_DECLINED = "battery_opt_declined"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ type ResolverChecker struct {
|
||||
timeout time.Duration
|
||||
logFunc LogFunc
|
||||
onScanDone func([]string) // called after each completed scan with healthy resolvers
|
||||
autoScan bool // if true, run hourly periodic scans
|
||||
started atomic.Bool // guards against double-start
|
||||
scanMu sync.Mutex // protects scanCancel
|
||||
scanRunMu sync.Mutex // only one CheckNow at a time (via TryLock)
|
||||
@@ -51,6 +52,11 @@ func (rc *ResolverChecker) SetOnScanDone(fn func([]string)) {
|
||||
rc.onScanDone = fn
|
||||
}
|
||||
|
||||
// SetAutoScan enables or disables the hourly periodic health-check loop.
|
||||
func (rc *ResolverChecker) SetAutoScan(enabled bool) {
|
||||
rc.autoScan = enabled
|
||||
}
|
||||
|
||||
// Start begins the periodic health-check loop in the background.
|
||||
// An initial check runs immediately; subsequent checks happen every 10 minutes.
|
||||
// ctx controls the lifetime — cancel it to stop the checker.
|
||||
@@ -90,7 +96,9 @@ func (rc *ResolverChecker) StartAndNotify(ctx context.Context, onFirstDone func(
|
||||
onFirstDone()
|
||||
}
|
||||
|
||||
rc.runPeriodicLoop(ctx)
|
||||
if rc.autoScan {
|
||||
rc.runPeriodicLoop(ctx)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -102,7 +110,9 @@ func (rc *ResolverChecker) StartPeriodic(ctx context.Context) {
|
||||
if !rc.started.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go rc.runPeriodicLoop(ctx)
|
||||
if rc.autoScan {
|
||||
go rc.runPeriodicLoop(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// runPeriodicLoop is the shared Hour ticker loop used by both
|
||||
@@ -238,6 +248,7 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) bool {
|
||||
wg.Wait()
|
||||
|
||||
if scanCtx.Err() != nil {
|
||||
rc.log("RESOLVER_SCAN cancelled")
|
||||
return false // context cancelled — don't update resolver list
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
|
||||
@@ -247,7 +249,20 @@ func (xr *XPublicReader) fetchAccount(ctx context.Context, username string) ([]p
|
||||
lastErr = fmt.Errorf("%s: %w", instance, err)
|
||||
continue
|
||||
}
|
||||
return msgs, nil
|
||||
// Filter out garbled messages (invalid UTF-8 or mostly non-printable).
|
||||
cleaned := msgs[:0]
|
||||
for _, m := range msgs {
|
||||
if isReadableText(m.Text) {
|
||||
cleaned = append(cleaned, m)
|
||||
} else {
|
||||
log.Printf("[x] @%s: skipping garbled message ID=%d (len=%d)", username, m.ID, len(m.Text))
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
lastErr = fmt.Errorf("%s: all %d messages were garbled", instance, len(msgs))
|
||||
continue
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no Nitter instances configured")
|
||||
@@ -271,6 +286,7 @@ type xRSSItem struct {
|
||||
}
|
||||
|
||||
func parseXRSSMessages(body []byte, feedUser string) ([]protocol.Message, error) {
|
||||
body = sanitizeUTF8(body)
|
||||
var feed xRSS
|
||||
if err := xml.Unmarshal(body, &feed); err != nil {
|
||||
return nil, fmt.Errorf("parse rss: %w", err)
|
||||
@@ -542,3 +558,35 @@ func formatXQuoteMarkers(s string) string {
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// isReadableText returns true if s is valid UTF-8 and at least half of its
|
||||
// runes are printable (letters, digits, punctuation, symbols, spaces).
|
||||
// This filters out garbled binary data that some Nitter instances return.
|
||||
func isReadableText(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
if !utf8.ValidString(s) {
|
||||
return false
|
||||
}
|
||||
var total, printable int
|
||||
for _, r := range s {
|
||||
total++
|
||||
if unicode.IsPrint(r) || r == '\n' || r == '\r' || r == '\t' {
|
||||
printable++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return false
|
||||
}
|
||||
return float64(printable)/float64(total) >= 0.5
|
||||
}
|
||||
|
||||
// sanitizeUTF8 replaces invalid UTF-8 sequences with the Unicode replacement
|
||||
// character so xml.Unmarshal doesn't choke on broken encodings.
|
||||
func sanitizeUTF8(data []byte) []byte {
|
||||
if utf8.Valid(data) {
|
||||
return data
|
||||
}
|
||||
return []byte(strings.ToValidUTF8(string(data), "\uFFFD"))
|
||||
}
|
||||
|
||||
@@ -256,3 +256,24 @@ func TestParseXRSSMessages_PureRetweet(t *testing.T) {
|
||||
t.Fatalf("original content missing; got: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReadableText(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"Hello world", true},
|
||||
{"سلام دنیا", true},
|
||||
{"", false},
|
||||
{"\x00\x01\x02\x03\x04\x05", false},
|
||||
{"Y;\x80\x81 $ \x82) \x83\x84", false},
|
||||
{"abc\x00\x01\x02\x03\x04\x05\x06\x07\x08", false},
|
||||
{"Hello\nWorld\t!", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := isReadableText(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isReadableText(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,15 @@ func (s *Server) handleScannerStart(w http.ResponseWriter, r *http.Request) {
|
||||
Passphrase: profileCfg.Key,
|
||||
}
|
||||
|
||||
// Cancel any in-progress resolver checker scan to avoid resource
|
||||
// contention (both the checker and scanner do DNS probes).
|
||||
s.mu.RLock()
|
||||
checker := s.checker
|
||||
s.mu.RUnlock()
|
||||
if checker != nil {
|
||||
checker.CancelCurrentScan()
|
||||
}
|
||||
|
||||
s.scanner.SetLogFunc(func(msg string) {
|
||||
s.addLog(msg)
|
||||
})
|
||||
@@ -244,6 +253,14 @@ func (s *Server) handleScannerApply(w http.ResponseWriter, r *http.Request) {
|
||||
s.config = cfg
|
||||
s.mu.Unlock()
|
||||
}
|
||||
// Cancel any in-progress checker scan before re-initializing so the
|
||||
// old goroutine exits quickly and doesn't race with the new fetcher.
|
||||
s.mu.RLock()
|
||||
oldChecker := s.checker
|
||||
s.mu.RUnlock()
|
||||
if oldChecker != nil {
|
||||
oldChecker.CancelCurrentScan()
|
||||
}
|
||||
if err := s.initFetcher(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("init fetcher: %v", err), 500)
|
||||
return
|
||||
|
||||
@@ -1643,6 +1643,7 @@
|
||||
value="4" min="1" max="5" title="How many resolvers are queried at the same time for one block"></div>
|
||||
<div class="form-group"><label data-i18n="dns_timeout">DNS Query Timeout (seconds)</label><input type="number" id="peTimeout"
|
||||
value="15" min="1" max="60" step="1"></div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:8px"><input type="checkbox" id="peAutoScan" style="width:auto" checked><label for="peAutoScan" data-i18n="auto_scan">Automatic hourly resolver check</label></div>
|
||||
<!-- Channel Management (editing only) -->
|
||||
<div id="peChannelSection" style="display:none">
|
||||
<hr class="section-divider">
|
||||
@@ -1930,6 +1931,7 @@
|
||||
apply: 'اعمال',
|
||||
clear_bg: 'پاک کردن',
|
||||
dns_timeout: 'تایماوت DNS (ثانیه)',
|
||||
auto_scan: 'بررسی خودکار ریزالورها (هر ساعت)',
|
||||
scanner_clear_targets: '\uD83D\uDDD1 پاک کردن',
|
||||
},
|
||||
en: {
|
||||
@@ -2041,6 +2043,7 @@
|
||||
apply: 'Apply',
|
||||
clear_bg: 'Clear',
|
||||
dns_timeout: 'DNS Query Timeout (s)',
|
||||
auto_scan: 'Automatic hourly resolver check',
|
||||
scanner_clear_targets: '\uD83D\uDDD1 Clear',
|
||||
}
|
||||
};
|
||||
@@ -2497,6 +2500,7 @@
|
||||
document.getElementById('peRateLimit').value = p.config.rateLimit || 6;
|
||||
document.getElementById('peScatter').value = p.config.scatter || 4;
|
||||
document.getElementById('peTimeout').value = p.config.timeout || 15;
|
||||
document.getElementById('peAutoScan').checked = p.config.autoScan !== false;
|
||||
}
|
||||
document.getElementById('peChannelSection').style.display = '';
|
||||
var isActive = id === activeProfileId;
|
||||
@@ -2521,6 +2525,7 @@
|
||||
document.getElementById('peRateLimit').value = '6';
|
||||
document.getElementById('peScatter').value = '4';
|
||||
document.getElementById('peTimeout').value = '15';
|
||||
document.getElementById('peAutoScan').checked = true;
|
||||
document.getElementById('peChannelSection').style.display = 'none';
|
||||
}
|
||||
}
|
||||
@@ -2569,7 +2574,7 @@
|
||||
var key = document.getElementById('peKey').value;
|
||||
var resolvers = document.getElementById('peResolvers').value.trim().split(/[\n,]+/).map(function (s) { return s.trim() }).filter(Boolean);
|
||||
if (!domain || !key || !resolvers.length) { errEl.textContent = t('resolvers') + ' / ' + t('domain') + ' / ' + t('passphrase'); errEl.style.display = 'block'; return }
|
||||
var profile = { id: editingProfileId || '', nickname: nick || domain, config: { domain: domain, key: key, resolvers: resolvers, queryMode: document.getElementById('peQueryMode').value, rateLimit: parseFloat(document.getElementById('peRateLimit').value) || 6, scatter: parseInt(document.getElementById('peScatter').value) || 4, timeout: parseInt(document.getElementById('peTimeout').value) || 15 } };
|
||||
var profile = { id: editingProfileId || '', nickname: nick || domain, config: { domain: domain, key: key, resolvers: resolvers, queryMode: document.getElementById('peQueryMode').value, rateLimit: parseFloat(document.getElementById('peRateLimit').value) || 6, scatter: parseInt(document.getElementById('peScatter').value) || 4, timeout: parseInt(document.getElementById('peTimeout').value) || 15, autoScan: document.getElementById('peAutoScan').checked ? undefined : false } };
|
||||
var action = editingProfileId ? 'update' : 'create';
|
||||
var wasFirst = !profiles || !profiles.profiles || profiles.profiles.length === 0;
|
||||
// Check if we should skip resolver check (existing healthy resolvers)
|
||||
@@ -2953,6 +2958,12 @@
|
||||
var hintEl = document.getElementById('no-ch-hint'); if (hintEl) hintEl.innerHTML = resolverScanHint;
|
||||
return;
|
||||
}
|
||||
// RESOLVER_SCAN cancelled
|
||||
if (line.includes('RESOLVER_SCAN cancelled')) {
|
||||
resolverScanHint = '';
|
||||
if (item && item.parentNode) item.parentNode.removeChild(item);
|
||||
return;
|
||||
}
|
||||
// RESOLVER_SCAN done K/T
|
||||
var doneMatch = line.match(/RESOLVER_SCAN done (\d+)\/(\d+)/);
|
||||
if (doneMatch) {
|
||||
|
||||
@@ -41,6 +41,9 @@ type Config struct {
|
||||
// Scatter is the number of resolvers queried simultaneously per DNS block request
|
||||
// (0 or 1 = sequential, 4 = default parallel pair).
|
||||
Scatter int `json:"scatter,omitempty"`
|
||||
// AutoScan enables hourly automatic resolver health-check scans.
|
||||
// nil means enabled (default); set to a false pointer to disable.
|
||||
AutoScan *bool `json:"autoScan,omitempty"`
|
||||
}
|
||||
|
||||
// Profile wraps a Config with a user-chosen nickname and a unique ID.
|
||||
@@ -709,6 +712,9 @@ func (s *Server) initFetcher() error {
|
||||
s.saveLastScan(healthy)
|
||||
}
|
||||
})
|
||||
// nil means enabled (the default); only an explicit false pointer disables it.
|
||||
autoScan := cfg.AutoScan == nil || *cfg.AutoScan
|
||||
checker.SetAutoScan(autoScan)
|
||||
s.checker = checker
|
||||
|
||||
s.fetcher = fetcher
|
||||
|
||||
Reference in New Issue
Block a user