package net.hockeyapp.android.metrics;
import android.content.Context;
import android.text.TextUtils;
import net.hockeyapp.android.utils.HockeyLog;
import java.io.*;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.UUID;
/**
* <h3>Description</h3>
* <p/>
* Persistence layer to save and manage telemetry data on disk before sending.
* Telemetry data is saved in batches which make up one file. There is a maximum total number
* of telemetry files kept by the persistence, in order to not exceed disk storage limiations.
* If too many files are kept, the persistence will reject further persistence calls, but will
* not remove older telemetry files until they are sent.
*/
class Persistence {
/**
* The tag for logging.
*/
private static final String TAG = "HA-MetricsPersistence";
/**
* Synchronization lock.
*/
private static final Object LOCK = new Object();
/**
* Path for storing telemetry data files.
*/
private static final String BIT_TELEMETRY_DIRECTORY = "/net.hockeyapp.android/telemetry/";
/**
* Maximum number of telemetry files to allow on disk.
*/
private static final Integer MAX_FILE_COUNT = 50;
/**
* Directory of telemetry files.
*/
protected final File mTelemetryDirectory;
/**
* A weak reference to the app context.
*/
private final WeakReference<Context> mWeakContext;
/**
* Sender module used to send out files.
*/
protected WeakReference<Sender> mWeakSender;
/**
* List with paths of telemetry files which are currently being used by the sender for transmission.
*/
// TODO This looks like a violation of separation of concerns. Look into moving this to the sender.
protected ArrayList<File> mServedFiles;
/**
* Creates and initializes a new instance.
*
* @param context Android Context object.
* @param telemetryDirectory The directory where files should be saved.
* @param sender Sender instance which will take care of telemetry transmission.
*/
protected Persistence(Context context, File telemetryDirectory, Sender sender) {
mWeakContext = new WeakReference<>(context);
mServedFiles = new ArrayList<>(51);
mTelemetryDirectory = telemetryDirectory;
mWeakSender = new WeakReference<>(sender);
createDirectoriesIfNecessary();
}
/**
* Creates and initializes a new instance.
*
* @param context Android Context object.
* @param sender Sender instance which will take care of telemetry transmission.
*/
protected Persistence(Context context, Sender sender) {
this(context, new File(context.getFilesDir().getAbsolutePath() + BIT_TELEMETRY_DIRECTORY), null);
this.setSender(sender);
}
/**
* Persists serialized telemetry data to disk. Data points are joined by newlines, forming
* line delimited JSON streaming data. Triggers sending of the persisted data if writing
* was successful.
*
* @param data The data to save to disk.
* @see Persistence#writeToDisk(String)
*/
protected void persist(String[] data) {
if (!this.isFreeSpaceAvailable()) {
HockeyLog.warn(TAG, "Failed to persist file: Too many files on disk.");
getSender().triggerSending();
} else {
StringBuilder buffer = new StringBuilder();
boolean isSuccess;
for (String aData : data) {
if (buffer.length() > 0) {
buffer.append('\n');
}
buffer.append(aData);
}
String serializedData = buffer.toString();
isSuccess = writeToDisk(serializedData);
if (isSuccess) {
getSender().triggerSending();
}
}
}
/**
* Saves a string of serialized telemetry data objects to disk.
* It will create a random UUID file in the storage directory
* and save the data to this file.
*
* @param data The complete data string to save.
* @return True if the operation was successful, false otherwise.
*/
protected boolean writeToDisk(String data) {
String uuid = UUID.randomUUID().toString();
Boolean isSuccess = false;
FileOutputStream outputStream = null;
try {
synchronized (LOCK) {
File filesDir = new File(mTelemetryDirectory + "/" + uuid);
outputStream = new FileOutputStream(filesDir, true);
outputStream.write(data.getBytes());
HockeyLog.warn(TAG, "Saving data to: " + filesDir.toString());
}
isSuccess = true;
} catch (Exception e) {
HockeyLog.warn(TAG, "Failed to save data with exception: " + e.toString());
} finally {
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return isSuccess;
}
/**
* Retrieves string data from a given path.
*
* @param file Reference to a file on disk.
* @return The next item from disk, or empty string if anything goes wrong.
*/
protected String load(File file) {
StringBuilder buffer = new StringBuilder();
if (file != null) {
BufferedReader reader = null;
try {
synchronized (LOCK) {
FileInputStream inputStream = new FileInputStream(file);
InputStreamReader streamReader = new InputStreamReader(inputStream);
reader = new BufferedReader(streamReader);
int c;
while ((c = reader.read()) != -1) {
buffer.append((char) c);
}
}
} catch (Exception e) {
HockeyLog.warn(TAG, "Error reading telemetry data from file with exception message "
+ e.getMessage());
} finally {
try {
if (reader != null) {
reader.close();
}
} catch (IOException e) {
HockeyLog.warn(TAG, "Error closing stream."
+ e.getMessage());
}
}
}
return buffer.toString();
}
/**
* Checks, if there are telemetry files available for sending.
*
* @return True if files are available, false otherwise.
*/
protected boolean hasFilesAvailable() {
return nextAvailableFileInDirectory() != null;
}
/**
* Gets the next file with telemetry data to transmit.
*
* @return Reference to the next available file, null if no file is available.
*/
protected File nextAvailableFileInDirectory() {
// TODO Separation of concerns. The persistence should provide all files, the sender would pick the right one.
synchronized (LOCK) {
if (mTelemetryDirectory != null) {
File[] files = mTelemetryDirectory.listFiles();
File file;
if ((files != null) && (files.length > 0)) {
for (int i = 0; i <= files.length - 1; i++) {
file = files[i];
if (!mServedFiles.contains(file)) {
HockeyLog.info(TAG, "The directory " + file.toString() + " (ADDING TO SERVED AND RETURN)");
mServedFiles.add(file);
return file;
} else {
HockeyLog.info(TAG, "The directory " + file.toString() + " (WAS ALREADY SERVED)");
}
}
}
}
if (mTelemetryDirectory != null) {
HockeyLog.info(TAG, "The directory " + mTelemetryDirectory.toString() + " did not contain any " +
"unserved files");
}
return null;
}
}
/**
* Deletes a file from disk and removes it from the list of served files, if deletion was successful.
*
* @param file Reference to the file to delete.
*/
protected void deleteFile(File file) {
if (file != null) {
synchronized (LOCK) {
boolean deletedFile = file.delete();
if (!deletedFile) {
HockeyLog.warn(TAG, "Error deleting telemetry file " + file.toString());
} else {
HockeyLog.warn(TAG, "Successfully deleted telemetry file at: " + file.toString());
mServedFiles.remove(file);
}
}
} else {
HockeyLog.warn(TAG, "Couldn't delete file, the reference to the file was null");
}
}
/**
* Remove a file from the list of served files. Remove files from the served list
* that should be made available so it can be sent again later.
*
* @param file Reference to the file to remove from the list.
*/
protected void makeAvailable(File file) {
synchronized (LOCK) {
if (file != null) {
mServedFiles.remove(file);
}
}
}
/**
* Checks whether there is a slot left for a telemetry file.
* @return True if there is still space for another telemetry file.
*/
protected boolean isFreeSpaceAvailable() {
// TODO Check for available disk space as well.
synchronized (LOCK) {
Context context = getContext();
if (context.getFilesDir() != null) {
File filesDir = context.getFilesDir();
String path = filesDir.getAbsolutePath() + BIT_TELEMETRY_DIRECTORY;
if (!TextUtils.isEmpty(path)) {
File dir = new File(path);
File[] files = dir.listFiles();
return files != null && files.length < MAX_FILE_COUNT;
}
}
return false;
}
}
/**
* Create directory structure for telemetry data.
*/
protected void createDirectoriesIfNecessary() {
String successMessage = "Successfully created directory";
String errorMessage = "Error creating directory";
if (mTelemetryDirectory != null && !mTelemetryDirectory.exists()) {
if (mTelemetryDirectory.mkdirs()) {
HockeyLog.info(TAG, successMessage);
} else {
HockeyLog.info(TAG, errorMessage);
}
}
}
/**
* Retrieves the context from the weak reference.
*
* @return The context object for this instance.
*/
private Context getContext() {
Context context = null;
if (mWeakContext != null) {
context = mWeakContext.get();
}
return context;
}
/**
* Retrieves the sender from the weak reference.
*
* @return The sender object for this instance.
*/
protected Sender getSender() {
Sender sender = null;
if (mWeakSender != null) {
sender = mWeakSender.get();
}
return sender;
}
/**
* Set the sender for this instance. Stores a weak reference to the sender.
*
* @param sender The sender to store for this instance.
*/
protected void setSender(Sender sender) {
this.mWeakSender = new WeakReference<>(sender);
}
}