package net.i2p.android.apps; import android.content.Context; import net.i2p.android.router.NewsActivity; import net.i2p.android.router.R; import net.i2p.android.router.util.Notifications; import net.i2p.crypto.SU3File; import net.i2p.data.DataHelper; import net.i2p.router.RouterContext; import net.i2p.router.news.NewsEntry; import net.i2p.router.news.NewsMetadata; import net.i2p.router.news.NewsXMLParser; import net.i2p.router.util.RFC822Date; import net.i2p.util.EepGet; import net.i2p.util.FileUtil; import net.i2p.util.Log; import net.i2p.util.ReusableGZIPInputStream; import net.i2p.util.SecureFileOutputStream; import java.io.BufferedWriter; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; import java.util.Date; import java.util.List; /** * From router console, simplified since we don't deal with router versions * or updates. */ public class NewsFetcher implements Runnable, EepGet.StatusListener { private final Context mCtx; private final RouterContext _context; private final Notifications _notif; private final Log _log; private long _lastFetch; private long _lastUpdated; private String _lastModified; private boolean _invalidated; private File _newsFile; private File _tempFile; private static NewsFetcher _instance; private volatile boolean _isRunning = true; private Thread _thread; public static /*final */ NewsFetcher getInstance() { return _instance; } public static /* final */ synchronized NewsFetcher getInstance( Context context, RouterContext ctx, Notifications notif) { if (_instance != null) return _instance; _instance = new NewsFetcher(context, ctx, notif); return _instance; } private static final String NEWS_DIR = "docs"; private static final String NEWS_FILE = "news.xml"; private static final String TEMP_NEWS_FILE = "news.xml.temp"; /** * Changed in 0.9.11 to the b32 for psi.i2p, run by psi. * We may be able to change it to psi.i2p in a future release after * the hostname propagates. * * @since 0.7.14 not configurable */ private static final String BACKUP_NEWS_URL_SU3 = "http://avviiexdngd32ccoy4kuckvc3mkf53ycvzbz6vz75vzhv4tbpk5a.b32.i2p/news.su3"; private static final String PROP_LAST_CHECKED = "router.newsLastChecked"; private static final String PROP_REFRESH_FREQUENCY = "router.newsRefreshFrequency"; private static final String DEFAULT_REFRESH_FREQUENCY = 24 * 60 * 60 * 1000 + ""; private static final String PROP_NEWS_URL = "router.newsURL"; public static final String DEFAULT_NEWS_URL_SU3 = "http://echelon.i2p/news/news.su3"; private NewsFetcher(Context context, RouterContext ctx, Notifications notif) { mCtx = context; _context = ctx; _notif = notif; _context.addShutdownTask(new Shutdown()); _log = ctx.logManager().getLog(NewsFetcher.class); try { String last = ctx.getProperty(PROP_LAST_CHECKED); if (last != null) _lastFetch = Long.parseLong(last); } catch (NumberFormatException nfe) { } File newsDir = new File(_context.getRouterDir(), NEWS_DIR); // isn't already there on android newsDir.mkdir(); _newsFile = new File(newsDir, NEWS_FILE); _tempFile = new File(_context.getTempDir(), TEMP_NEWS_FILE); updateLastFetched(); } private void updateLastFetched() { if (_newsFile.exists()) { if (_lastUpdated == 0) _lastUpdated = _newsFile.lastModified(); if (_lastFetch == 0) _lastFetch = _lastUpdated; if (_lastModified == null) _lastModified = RFC822Date.to822Date(_lastFetch); } else { _lastUpdated = 0; _lastFetch = 0; _lastModified = null; } } public String status() { StringBuilder buf = new StringBuilder(128); long now = _context.clock().now(); if (_lastUpdated > 0) { buf.append(mCtx.getString(R.string.news_last_updated, DataHelper.formatDuration2(now - _lastUpdated))) .append('\n'); } if (_lastFetch > _lastUpdated) { buf.append(mCtx.getString(R.string.news_last_checked, DataHelper.formatDuration2(now - _lastFetch))); } return buf.toString(); } // Runnable private static final long INITIAL_DELAY = 5 * 60 * 1000; private static final long RUN_DELAY = 30 * 60 * 1000; public void run() { _thread = Thread.currentThread(); try { Thread.sleep(INITIAL_DELAY); } catch (InterruptedException ie) { return; } while (_isRunning && _context.router().isAlive()) { if (shouldFetchNews()) { fetchNews(); } try { Thread.sleep(RUN_DELAY); } catch (InterruptedException ie) { break; } } } private boolean shouldFetchNews() { if (_invalidated) return true; updateLastFetched(); String freq = _context.getProperty(PROP_REFRESH_FREQUENCY, DEFAULT_REFRESH_FREQUENCY); try { long ms = Long.parseLong(freq); if (ms <= 0) return false; if (_lastFetch + ms < _context.clock().now()) { return true; } else { if (_log.shouldLog(Log.DEBUG)) _log.debug("Last fetched " + DataHelper.formatDuration(_context.clock().now() - _lastFetch) + " ago"); return false; } } catch (NumberFormatException nfe) { if (_log.shouldLog(Log.ERROR)) _log.error("Invalid refresh frequency: " + freq); return false; } } /** * Call this when changing news URLs to force an update next time the timer fires. * * @since 0.8.7 */ void invalidateNews() { _lastModified = null; _invalidated = true; } public void fetchNews() { String newsURL = _context.getProperty(PROP_NEWS_URL, DEFAULT_NEWS_URL_SU3); String proxyHost = "127.0.0.1"; int proxyPort = 4444; if (_tempFile.exists()) _tempFile.delete(); try { // EepGet get = null; EepGet get = new EepGet(_context, true, proxyHost, proxyPort, 0, _tempFile.getAbsolutePath(), newsURL, true, null, _lastModified); get.addStatusListener(this); if (get.fetch()) { _lastModified = get.getLastModified(); _invalidated = false; } else { // backup news location - always proxied _tempFile.delete(); get = new EepGet(_context, true, proxyHost, proxyPort, 0, _tempFile.getAbsolutePath(), BACKUP_NEWS_URL_SU3, true, null, _lastModified); get.addStatusListener(this); if (get.fetch()) _lastModified = get.getLastModified(); } } catch (Throwable t) { _log.error("Error fetching the news", t); } } // EepGet.StatusListener public void bytesTransferred(long alreadyTransferred, int currentWrite, long bytesTransferred, long bytesRemaining, String url) { // ignore } public void transferComplete(long alreadyTransferred, long bytesTransferred, long bytesRemaining, String url, String outputFile, boolean notModified) { if (_log.shouldLog(Log.INFO)) _log.info("News fetched from " + url + " with " + (alreadyTransferred + bytesTransferred)); long now = _context.clock().now(); if (_tempFile.exists() && _tempFile.length() > 0) { File from; if (url.endsWith(".su3")) { try { from = processSU3(); } catch (IOException ioe) { _log.error("Failed to extract the news file", ioe); _tempFile.delete(); return; } } else { from = _tempFile; } boolean copied = FileUtil.rename(from, _newsFile); _tempFile.delete(); if (copied) { _lastUpdated = now; // Notify user _notif.notify(mCtx.getString(R.string.news_updated), mCtx.getString(R.string.view_news), NewsActivity.class); } else { if (_log.shouldLog(Log.ERROR)) _log.error("Failed to copy the news file!"); } } else { if (_log.shouldLog(Log.WARN)) _log.warn("Transfer complete, but no file? - probably 304 Not Modified"); } _lastFetch = now; _context.router().setConfigSetting(PROP_LAST_CHECKED, "" + now); _context.router().saveConfig(); } public void attemptFailed(String url, long bytesTransferred, long bytesRemaining, int currentAttempt, int numRetries, Exception cause) { // ignore } public void transferFailed(String url, long bytesTransferred, long bytesRemaining, int currentAttempt) { if (_log.shouldLog(Log.WARN)) _log.warn("Failed to fetch the news from " + url); _tempFile.delete(); } public void headerReceived(String url, int attemptNum, String key, String val) { } public void attempting(String url) { } private class Shutdown implements Runnable { public void run() { _isRunning = false; if (_thread != null) _thread.interrupt(); } } // // SU3 handlers // /** * Process the fetched su3 news file _tempFile. * Handles 3 types of contained files: xml.gz (preferred), xml, and html (old format fake xml) * * @return the temp file contining the HTML-format news.xml * @since 0.9.20 */ private File processSU3() throws IOException { SU3File su3 = new SU3File(_context, _tempFile); // real xml, maybe gz, maybe not File to1 = new File(_context.getTempDir(), "tmp-" + _context.random().nextInt() + ".xml"); // real xml File to2 = new File(_context.getTempDir(), "tmp2-" + _context.random().nextInt() + ".xml"); try { su3.verifyAndMigrate(to1); int type = su3.getFileType(); if (su3.getContentType() != SU3File.CONTENT_NEWS) throw new IOException("bad content type: " + su3.getContentType()); if (type == SU3File.TYPE_HTML) return to1; if (type != SU3File.TYPE_XML && type != SU3File.TYPE_XML_GZ) throw new IOException("bad file type: " + type); File xml; if (type == SU3File.TYPE_XML_GZ) { gunzip(to1, to2); xml = to2; to1.delete(); } else { xml = to1; } NewsXMLParser parser = new NewsXMLParser(_context); parser.parse(xml); xml.delete(); NewsMetadata data = parser.getMetadata(); List<NewsEntry> entries = parser.getEntries(); String sudVersion = su3.getVersionString(); String signingKeyName = su3.getSignerString(); File to3 = new File(_context.getTempDir(), "tmp3-" + _context.random().nextInt() + ".xml"); outputOldNewsXML(data, entries, sudVersion, signingKeyName, to3); return to3; } finally { to2.delete(); } } /** * Gunzip the file * * @since 0.9.20 */ private static void gunzip(File from, File to) throws IOException { ReusableGZIPInputStream in = ReusableGZIPInputStream.acquire(); OutputStream out = null; try { in.initialize(new FileInputStream(from)); out = new SecureFileOutputStream(to); byte buf[] = new byte[4096]; int read; while ((read = in.read(buf)) != -1) { out.write(buf, 0, read); } } finally { if (out != null) try { out.close(); } catch (IOException ioe) {} ReusableGZIPInputStream.release(in); } } /** * Output in the old format. * * @since 0.9.20 */ private void outputOldNewsXML(NewsMetadata data, List<NewsEntry> entries, String sudVersion, String signingKeyName, File to) throws IOException { NewsMetadata.Release latestRelease = data.releases.get(0); Writer out = null; try { out = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(to), "UTF-8")); out.write("<!--\n"); // update metadata in old format out.write("<i2p.release "); if (latestRelease.i2pVersion != null) out.write(" version=\"" + latestRelease.i2pVersion + '"'); if (latestRelease.minVersion != null) out.write(" minVersion=\"" + latestRelease.minVersion + '"'); if (latestRelease.minJavaVersion != null) out.write(" minJavaVersion=\"" + latestRelease.minJavaVersion + '"'); String su3Torrent = ""; String su2Torrent = ""; for (NewsMetadata.Update update : latestRelease.updates) { if (update.torrent != null) { if ("su3".equals(update.type)) su3Torrent = update.torrent; else if ("su2".equals(update.type)) su2Torrent = update.torrent; } } if (!su2Torrent.isEmpty()) out.write(" su2Torrent=\"" + su2Torrent + '"'); if (!su3Torrent.isEmpty()) out.write(" su3Torrent=\"" + su3Torrent + '"'); out.write("/>\n"); // su3 and feed metadata for debugging out.write("** News version:\t" + DataHelper.stripHTML(sudVersion) + '\n'); out.write("** Signed by:\t" + signingKeyName + '\n'); out.write("** Feed:\t" + DataHelper.stripHTML(data.feedTitle) + '\n'); out.write("** Feed ID:\t" + DataHelper.stripHTML(data.feedID) + '\n'); out.write("** Feed Date:\t" + (new Date(data.feedUpdated)) + '\n'); out.write("-->\n"); if (entries == null) return; for (NewsEntry e : entries) { if (e.title == null || e.content == null) continue; out.write("<!-- Entry Date: " + (new Date(e.updated)) + " -->\n"); out.write("<h3>"); out.write(e.title); out.write("</h3>\n"); out.write(e.content); out.write("\n\n"); } } finally { if (out != null) try { out.close(); } catch (IOException ioe) {} } } }