Java tutorial
/******************************************************************************* * This file is part of Zandy. * * Zandy is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Zandy is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Zandy. If not, see <http://www.gnu.org/licenses/>. ******************************************************************************/ package com.gimranov.zandy.app; import android.app.AlertDialog; import android.app.Dialog; import android.app.ListActivity; import android.app.ProgressDialog; import android.content.ActivityNotFoundException; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.preference.PreferenceManager; import android.text.Editable; import android.util.Base64; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.webkit.MimeTypeMap; import android.widget.AdapterView; import android.widget.AdapterView.OnItemClickListener; import android.widget.AdapterView.OnItemLongClickListener; import android.widget.ArrayAdapter; import android.widget.EditText; import android.widget.ImageView; import android.widget.ListView; import android.widget.TextView; import android.widget.TextView.BufferType; import android.widget.Toast; import com.crashlytics.android.Crashlytics; import com.gimranov.zandy.app.data.Attachment; import com.gimranov.zandy.app.data.Database; import com.gimranov.zandy.app.data.Item; import com.gimranov.zandy.app.task.APIRequest; import com.gimranov.zandy.app.task.ZoteroAPITask; import com.gimranov.zandy.app.webdav.WebDavTrust; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.CountingOutputStream; import org.json.JSONException; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.Authenticator; import java.net.MalformedURLException; import java.net.PasswordAuthentication; import java.net.URL; import java.net.URLConnection; import java.util.ArrayList; import java.util.Enumeration; import java.util.concurrent.atomic.AtomicInteger; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; /** * This Activity handles displaying and editing attachments. It works almost the same as * ItemDataActivity and TagActivity, using a simple ArrayAdapter on Bundles with the creator info. * * This currently operates by showing the attachments for a given item * * @author ajlyon */ public class AttachmentActivity extends ListActivity { private static final String TAG = "com.gimranov.zandy.app.AttachmentActivity"; static final int DIALOG_CONFIRM_NAVIGATE = 4; static final int DIALOG_FILE_PROGRESS = 6; static final int DIALOG_CONFIRM_DELETE = 5; static final int DIALOG_NOTE = 3; static final int DIALOG_NEW = 1; public Item item; private ProgressDialog mProgressDialog; private ProgressThread progressThread; private Database db; /** * For <= Android 2.1 (API 7), we can't pass bundles to showDialog(), so set this instead */ private Bundle b = new Bundle(); private ArrayList<File> tmpFiles; /** Called when the activity is first created. */ @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); tmpFiles = new ArrayList<File>(); db = new Database(this); /* Get the incoming data from the calling activity */ final String itemKey = getIntent().getStringExtra("com.gimranov.zandy.app.itemKey"); Item item = Item.load(itemKey, db); this.item = item; if (item == null) { Log.e(TAG, "AttachmentActivity started without itemKey; finishing."); finish(); return; } this.setTitle(getResources().getString(R.string.attachments_for_item, item.getTitle())); ArrayList<Attachment> rows = Attachment.forItem(item, db); /* * We use the standard ArrayAdapter, passing in our data as a Attachment. * Since it's no longer a simple TextView, we need to override getView, but * we can do that anonymously. */ setListAdapter(new ArrayAdapter<Attachment>(this, R.layout.list_attach, rows) { @Override public View getView(int position, View convertView, ViewGroup parent) { View row; // We are reusing views, but we need to initialize it if null if (null == convertView) { LayoutInflater inflater = getLayoutInflater(); row = inflater.inflate(R.layout.list_attach, null); } else { row = convertView; } ImageView tvType = (ImageView) row.findViewById(R.id.attachment_type); TextView tvSummary = (TextView) row.findViewById(R.id.attachment_summary); Attachment att = getItem(position); Log.d(TAG, "Have an attachment: " + att.title + " fn:" + att.filename + " status:" + att.status); tvType.setImageResource(Item.resourceForType(att.getType())); try { Log.d(TAG, att.content.toString(4)); } catch (JSONException e) { Log.e(TAG, "JSON parse exception when reading attachment content", e); } if (att.getType().equals("note")) { String note = att.content.optString("note", ""); if (note.length() > 40) { note = note.substring(0, 40); } tvSummary.setText(note); } else { StringBuffer status = new StringBuffer(getResources().getString(R.string.status)); if (att.status == Attachment.AVAILABLE) status.append(getResources().getString(R.string.attachment_zfs_available)); else if (att.status == Attachment.LOCAL) status.append(getResources().getString(R.string.attachment_zfs_local)); else status.append(getResources().getString(R.string.attachment_unknown)); tvSummary.setText(att.title + " " + status.toString()); } return row; } }); ListView lv = getListView(); lv.setTextFilterEnabled(true); lv.setOnItemLongClickListener(new OnItemLongClickListener() { // Warning here because Eclipse can't tell whether my ArrayAdapter is // being used with the correct parametrization. @SuppressWarnings("unchecked") public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { // If we have a click on an entry, show its note ArrayAdapter<Attachment> adapter = (ArrayAdapter<Attachment>) parent.getAdapter(); Attachment row = adapter.getItem(position); if (row.content.has("note")) { Log.d(TAG, "Trying to start note view activity for: " + row.key); Intent i = new Intent(getBaseContext(), NoteActivity.class); i.putExtra("com.gimranov.zandy.app.attKey", row.key);//row.content.optString("note", "")); startActivity(i); } return true; } }); lv.setOnItemClickListener(new OnItemClickListener() { // Warning here because Eclipse can't tell whether my ArrayAdapter is // being used with the correct parametrization. @SuppressWarnings("unchecked") public void onItemClick(AdapterView<?> parent, View view, int position, long id) { // If we have a long click on an entry, do something... ArrayAdapter<Attachment> adapter = (ArrayAdapter<Attachment>) parent.getAdapter(); Attachment row = adapter.getItem(position); String url = (row.url != null && !row.url.equals("")) ? row.url : row.content.optString("url"); if (!row.getType().equals("note")) { Bundle b = new Bundle(); b.putString("title", row.title); b.putString("attachmentKey", row.key); b.putString("content", url); SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(getBaseContext()); int linkMode = row.content.optInt("linkMode", Attachment.MODE_IMPORTED_URL); if (settings.getBoolean("webdav_enabled", false)) b.putString("mode", "webdav"); else b.putString("mode", "zfs"); if (linkMode == Attachment.MODE_IMPORTED_FILE || linkMode == Attachment.MODE_IMPORTED_URL) { loadFileAttachment(b); } else { AttachmentActivity.this.b = b; showDialog(DIALOG_CONFIRM_NAVIGATE); } } if (row.getType().equals("note")) { Bundle b = new Bundle(); b.putString("attachmentKey", row.key); b.putString("itemKey", itemKey); b.putString("content", row.content.optString("note", "")); removeDialog(DIALOG_NOTE); AttachmentActivity.this.b = b; showDialog(DIALOG_NOTE); } return; } }); } @Override public void onDestroy() { if (db != null) db.close(); if (tmpFiles != null) { for (File f : tmpFiles) { if (!f.delete()) { Log.e(TAG, "Failed to delete temporary file on activity close."); } } tmpFiles.clear(); } super.onDestroy(); } @Override public void onResume() { if (db == null) db = new Database(this); super.onResume(); } @Override protected Dialog onCreateDialog(int id) { final String attachmentKey = b.getString("attachmentKey"); final String itemKey = b.getString("itemKey"); final String content = b.getString("content"); final String mode = b.getString("mode"); AlertDialog dialog; switch (id) { case DIALOG_CONFIRM_NAVIGATE: dialog = new AlertDialog.Builder(this).setTitle(getResources().getString(R.string.view_online_warning)) .setPositiveButton(getResources().getString(R.string.view), new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int whichButton) { // The behavior for invalid URIs might be nasty, but // we'll cross that bridge if we come to it. try { Uri uri = Uri.parse(content); startActivity(new Intent(Intent.ACTION_VIEW).setData(uri)); } catch (ActivityNotFoundException e) { // There can be exceptions here; not sure what would prompt us to have // URIs that the browser can't load, but it apparently happens. Toast.makeText(getApplicationContext(), getResources() .getString(R.string.attachment_intent_failed_for_uri, content), Toast.LENGTH_SHORT).show(); } } }) .setNeutralButton(getResources().getString(R.string.cancel), new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int whichButton) { // do nothing } }) .create(); return dialog; case DIALOG_CONFIRM_DELETE: dialog = new AlertDialog.Builder(this) .setTitle(getResources().getString(R.string.attachment_delete_confirm)) .setPositiveButton(getResources().getString(R.string.menu_delete), new DialogInterface.OnClickListener() { @SuppressWarnings("unchecked") public void onClick(DialogInterface dialog, int whichButton) { Attachment a = Attachment.load(attachmentKey, db); a.delete(db); ArrayAdapter<Attachment> la = (ArrayAdapter<Attachment>) getListAdapter(); la.clear(); for (Attachment at : Attachment.forItem(Item.load(itemKey, db), db)) { la.add(at); } } }) .setNegativeButton(getResources().getString(R.string.cancel), new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int whichButton) { // do nothing } }) .create(); return dialog; case DIALOG_NOTE: final EditText input = new EditText(this); input.setText(content, BufferType.EDITABLE); AlertDialog.Builder builder = new AlertDialog.Builder(this) .setTitle(getResources().getString(R.string.note)).setView(input).setPositiveButton( getResources().getString(R.string.ok), new DialogInterface.OnClickListener() { @SuppressWarnings("unchecked") public void onClick(DialogInterface dialog, int whichButton) { Editable value = input.getText(); String fixed = value.toString().replaceAll("\n\n", "\n<br>"); if (mode != null && mode.equals("new")) { Log.d(TAG, "Attachment created with parent key: " + itemKey); Attachment att = new Attachment(getBaseContext(), "note", itemKey); att.setNoteText(fixed); att.dirty = APIRequest.API_NEW; att.save(db); } else { Attachment att = Attachment.load(attachmentKey, db); att.setNoteText(fixed); att.dirty = APIRequest.API_DIRTY; att.save(db); } ArrayAdapter<Attachment> la = (ArrayAdapter<Attachment>) getListAdapter(); la.clear(); for (Attachment a : Attachment.forItem(Item.load(itemKey, db), db)) { la.add(a); } la.notifyDataSetChanged(); } }) .setNeutralButton(getResources().getString(R.string.cancel), new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int whichButton) { // do nothing } }); // We only want the delete option when this isn't a new note if (mode == null || !"new".equals(mode)) { builder = builder.setNegativeButton(getResources().getString(R.string.menu_delete), new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int whichButton) { Bundle b = new Bundle(); b.putString("attachmentKey", attachmentKey); b.putString("itemKey", itemKey); removeDialog(DIALOG_CONFIRM_DELETE); AttachmentActivity.this.b = b; showDialog(DIALOG_CONFIRM_DELETE); } }); } dialog = builder.create(); return dialog; case DIALOG_FILE_PROGRESS: mProgressDialog = new ProgressDialog(this); mProgressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); mProgressDialog .setMessage(getResources().getString(R.string.attachment_downloading, b.getString("title"))); mProgressDialog.setIndeterminate(true); return mProgressDialog; default: Log.e(TAG, "Invalid dialog requested"); return null; } } protected void onPrepareDialog(int id, Dialog dialog) { switch (id) { case DIALOG_FILE_PROGRESS: mProgressDialog .setMessage(getResources().getString(R.string.attachment_downloading, b.getString("title"))); progressThread = new ProgressThread(handler, b); progressThread.start(); } } private void showAttachment(Attachment att) { if (att.status == Attachment.LOCAL) { Log.d(TAG, "Starting to display local attachment"); Uri uri = Uri.fromFile(new File(att.filename)); String mimeType = att.content.optString("mimeType", "application/pdf"); try { startActivity(new Intent(Intent.ACTION_VIEW).setDataAndType(uri, mimeType)); } catch (ActivityNotFoundException e) { Log.e(TAG, "No activity for intent", e); Toast.makeText(getApplicationContext(), getResources().getString(R.string.attachment_intent_failed, mimeType), Toast.LENGTH_SHORT) .show(); } } } /** * This mainly is to move the logic out of the onClick callback above * Decides whether to download or view, and launches the appropriate action * * @param b */ private void loadFileAttachment(Bundle b) { Attachment att = Attachment.load(b.getString("attachmentKey"), db); if (!ServerCredentials.sBaseStorageDir.exists()) ServerCredentials.sBaseStorageDir.mkdirs(); if (!ServerCredentials.sDocumentStorageDir.exists()) ServerCredentials.sDocumentStorageDir.mkdirs(); File attFile = new File(att.filename); if (att.status == Attachment.AVAILABLE // Zero-length or nonexistent gives length == 0 || (attFile != null && attFile.length() == 0)) { Log.d(TAG, "Starting to try and download attachment (status: " + att.status + ", fn: " + att.filename + ")"); this.b = b; showDialog(DIALOG_FILE_PROGRESS); } else showAttachment(att); } /** * Refreshes the current list adapter */ @SuppressWarnings("unchecked") private void refreshView() { ArrayAdapter<Attachment> la = (ArrayAdapter<Attachment>) getListAdapter(); la.clear(); for (Attachment at : Attachment.forItem(item, db)) { la.add(at); } } final Handler handler = new Handler() { public void handleMessage(Message msg) { switch (msg.arg2) { case ProgressThread.STATE_DONE: if (mProgressDialog.isShowing()) dismissDialog(DIALOG_FILE_PROGRESS); refreshView(); if (null != msg.obj) showAttachment((Attachment) msg.obj); break; case ProgressThread.STATE_FAILED: // Notify that we failed to get anything Toast.makeText(getApplicationContext(), getResources().getString(R.string.attachment_no_download_url), Toast.LENGTH_SHORT).show(); if (mProgressDialog.isShowing()) dismissDialog(DIALOG_FILE_PROGRESS); // Let's try to fall back on an online version AttachmentActivity.this.b = msg.getData(); showDialog(DIALOG_CONFIRM_NAVIGATE); refreshView(); break; case ProgressThread.STATE_UNZIPPING: mProgressDialog.setMax(msg.arg1); mProgressDialog.setProgress(0); mProgressDialog.setMessage(getResources().getString(R.string.attachment_unzipping)); break; case ProgressThread.STATE_RUNNING: mProgressDialog.setMax(msg.arg1); mProgressDialog.setProgress(0); mProgressDialog.setIndeterminate(false); break; default: mProgressDialog.setProgress(msg.arg1); break; } } }; private class ProgressThread extends Thread { Handler mHandler; Bundle arguments; final static int STATE_DONE = 5; final static int STATE_FAILED = 3; final static int STATE_RUNNING = 1; final static int STATE_UNZIPPING = 6; ProgressThread(Handler h, Bundle b) { mHandler = h; arguments = b; } @SuppressWarnings("unchecked") public void run() { // Setup final String attachmentKey = arguments.getString("attachmentKey"); // Can't fetch if we have nothing to fetch if (attachmentKey == null) return; final String mode = arguments.getString("mode"); URL url; File file; String urlstring; Attachment att = Attachment.load(attachmentKey, db); String sanitized = att.title.replace(' ', '_'); // If no 1-6-character extension, try to add one using MIME type if (!sanitized.matches(".*\\.[a-zA-Z0-9]{1,6}$")) { String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(att.getType()); if (extension != null) sanitized = sanitized + "." + extension; } sanitized = sanitized.replaceFirst("^(.*?)(\\.[^.]*)?$", "$1" + "_" + att.key + "$2"); file = new File(ServerCredentials.sDocumentStorageDir, sanitized); if (!ServerCredentials.sBaseStorageDir.exists()) ServerCredentials.sBaseStorageDir.mkdirs(); if (!ServerCredentials.sDocumentStorageDir.exists()) ServerCredentials.sDocumentStorageDir.mkdirs(); final SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(getBaseContext()); if ("webdav".equals(mode)) { //urlstring = "https://dfs.humnet.ucla.edu/home/ajlyon/zotero/223RMC7C.zip"; //urlstring = "http://www.gimranov.com/research/zotero/223RMC7C.zip"; urlstring = settings.getString("webdav_path", "") + "/" + att.key + ".zip"; Authenticator.setDefault(new Authenticator() { protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication(settings.getString("webdav_username", ""), settings.getString("webdav_password", "").toCharArray()); } }); if (settings.getBoolean("webdav_ssl_override", false)) { WebDavTrust.installAllTrustingCertificate(); } } else { urlstring = att.url + "?key=" + settings.getString("user_key", ""); } try { try { url = new URL(urlstring); } catch (MalformedURLException e) { // Alert that we don't have a valid download URL and return Message msg = mHandler.obtainMessage(); msg.arg2 = STATE_FAILED; msg.setData(arguments); mHandler.sendMessage(msg); Log.e(TAG, "Download URL not valid: " + urlstring, e); return; } //this is the downloader method long startTime = System.currentTimeMillis(); Log.d(TAG, "download beginning"); Log.d(TAG, "download url:" + url.toString()); Log.d(TAG, "downloaded file name:" + file.getPath()); /* Open a connection to that URL. */ URLConnection ucon = url.openConnection(); ucon.setRequestProperty("User-Agent", "Mozilla/5.0 ( compatible ) "); ucon.setRequestProperty("Accept", "*/*"); Message msg = mHandler.obtainMessage(); msg.arg1 = ucon.getContentLength(); msg.arg2 = STATE_RUNNING; mHandler.sendMessage(msg); /* * Define InputStreams to read from the URLConnection. */ InputStream is = null; FileOutputStream fos = null; try { fos = new FileOutputStream(file); final AtomicInteger counter = new AtomicInteger(); OutputStream outputStream = new CountingOutputStream(fos) { @Override protected void afterWrite(int n) throws IOException { super.afterWrite(n); if (n > 0) { int completed = counter.addAndGet(n); Message message = mHandler.obtainMessage(); message.arg1 = completed; mHandler.sendMessage(message); } } }; is = ucon.getInputStream(); IOUtils.copy(is, outputStream); } finally { if (is != null) { is.close(); } if (fos != null) { fos.close(); } } /* Save to temporary directory for WebDAV */ if ("webdav".equals(mode)) { if (!ServerCredentials.sCacheDir.exists()) { //noinspection ResultOfMethodCallIgnored ServerCredentials.sCacheDir.mkdirs(); } File tmpFile = File.createTempFile("zandy", ".zip", ServerCredentials.sCacheDir); FileUtils.copyFile(file, tmpFile); //noinspection ResultOfMethodCallIgnored file.delete(); // Keep track of temp files that we've created. if (tmpFiles == null) tmpFiles = new ArrayList<File>(); tmpFiles.add(tmpFile); ZipFile zf = new ZipFile(tmpFile); Enumeration<ZipEntry> entries = (Enumeration<ZipEntry>) zf.entries(); do { ZipEntry entry = entries.nextElement(); // Change the message to reflect that we're unzipping now msg = mHandler.obtainMessage(); msg.arg1 = (int) entry.getSize(); msg.arg2 = STATE_UNZIPPING; mHandler.sendMessage(msg); String name64 = entry.getName(); try { byte[] byteName = Base64.decode(name64.getBytes(), 0, name64.length() - 5, Base64.DEFAULT); String name = new String(byteName); Log.d(TAG, "Found file " + name + " from encoded " + name64); // If the linkMode is not an imported URL (snapshot) and the MIME type isn't text/html, // then we unzip it and we're happy. If either of the preceding is true, we skip the file // unless the filename includes .htm (covering .html automatically) if ((!att.getType().equals("text/html")) || name.contains(".htm")) { FileOutputStream fos2 = new FileOutputStream(file); InputStream entryStream = zf.getInputStream(entry); final AtomicInteger counter = new AtomicInteger(); OutputStream outputStream = new CountingOutputStream(fos2) { @Override protected void afterWrite(int n) throws IOException { super.afterWrite(n); if (n > 0) { int completed = counter.addAndGet(n); Message message = mHandler.obtainMessage(); message.arg1 = completed; mHandler.sendMessage(message); } } }; IOUtils.copy(entryStream, outputStream); fos2.close(); entryStream.close(); Log.d(TAG, "Finished reading file"); } else { Log.d(TAG, "Skipping file: " + name); } } catch (IllegalArgumentException e) { Crashlytics.logException(new Throwable("b64 " + name64, e)); } catch (NegativeArraySizeException e) { Crashlytics.logException(new Throwable("b64 " + name64, e)); } } while (entries.hasMoreElements()); zf.close(); // We remove the file from the ArrayList if deletion succeeded; // otherwise deletion is put off until the activity exits. if (tmpFile.delete()) { tmpFiles.remove(tmpFile); } } Log.d(TAG, "download ready in " + ((System.currentTimeMillis() - startTime) / 1000) + " sec"); } catch (IOException e) { Log.e(TAG, "Error: ", e); Crashlytics.logException(e); toastError(R.string.attachment_download_failed, e.getMessage()); } att.filename = file.getPath(); File newFile = new File(att.filename); Message msg = mHandler.obtainMessage(); if (newFile.length() > 0) { att.status = Attachment.LOCAL; Log.d(TAG, "File downloaded: " + att.filename); msg.obj = att; } else { Log.d(TAG, "File not downloaded: " + att.filename); att.status = Attachment.AVAILABLE; msg.obj = null; } att.save(db); msg.arg2 = STATE_DONE; mHandler.sendMessage(msg); } } private void toastError(final int resource, final String detail) { AttachmentActivity.this.runOnUiThread(new Runnable() { @Override public void run() { Toast.makeText(AttachmentActivity.this, AttachmentActivity.this.getString(resource) + "\n " + detail, Toast.LENGTH_LONG).show(); } }); } @Override public boolean onCreateOptionsMenu(Menu menu) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.zotero_menu, menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { Bundle b = new Bundle(); // Handle item selection switch (item.getItemId()) { case R.id.do_sync: if (!ServerCredentials.check(getApplicationContext())) { Toast.makeText(getApplicationContext(), getResources().getString(R.string.sync_log_in_first), Toast.LENGTH_SHORT).show(); return true; } Log.d(TAG, "Preparing sync requests, starting with present item"); new ZoteroAPITask(getBaseContext()).execute(APIRequest.update(this.item)); Toast.makeText(getApplicationContext(), getResources().getString(R.string.sync_started), Toast.LENGTH_SHORT).show(); return true; case R.id.do_new: b.putString("itemKey", this.item.getKey()); b.putString("mode", "new"); removeDialog(DIALOG_NOTE); showDialog(DIALOG_NOTE); return true; case R.id.do_prefs: startActivity(new Intent(this, SettingsActivity.class)); return true; default: return super.onOptionsItemSelected(item); } } }