callbacks/maoni-doorbell/src/main/java/org/rm3l/maoni/doorbell/MaoniDoorbellListener.java
/*
* Copyright (c) 2016-2022 Armel Soro
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package org.rm3l.maoni.doorbell;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;
import org.rm3l.maoni.common.contract.Listener;
import org.rm3l.maoni.common.model.DeviceInfo;
import org.rm3l.maoni.common.model.Feedback;
import org.rm3l.maoni.doorbell.api.DoorbellService;
import org.rm3l.maoni.doorbell.api.DoorbellSubmitRequest;
import org.rm3l.maoni.doorbell.api.MaoniDoorbellTransferListener;
import org.rm3l.maoni.doorbell.api.MaoniDoorbellTransferListener.MaoniDoorbellTransferProgress;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import needle.UiRelatedProgressTask;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
import static org.rm3l.maoni.doorbell.api.MaoniDoorbellTransferListener.MaoniDoorbellTransferProgress.COMPLETED;
import static org.rm3l.maoni.doorbell.api.MaoniDoorbellTransferListener.MaoniDoorbellTransferProgress.ERROR;
import static org.rm3l.maoni.doorbell.api.MaoniDoorbellTransferListener.MaoniDoorbellTransferProgress.STARTED;
import static org.rm3l.maoni.doorbell.api.MaoniDoorbellTransferListener.MaoniDoorbellTransferProgress.UPLOADING_FEEDBACK_CONTENT;
import static org.rm3l.maoni.doorbell.api.MaoniDoorbellTransferListener.MaoniDoorbellTransferProgress.UPLOADING_FILES_CAPTURED;
/**
* Callback for Maoni that takes care of sending the Feedback to Doorbell.io provider
*/
public class MaoniDoorbellListener implements Listener {
private static final String USER_AGENT = "maoni-doorbell (v8.0.2)";
private static final String FEEDBACK_API_BASE_URL = "https://doorbell.io/api/";
private static final String EMPTY_STRING = "";
private final int mApplicationId;
private final String mApplicationKey;
private final boolean mSkipFilesUpload;
private final Callable<CharSequence> mFeedbackHeaderTextProvider;
private final Callable<CharSequence> mFeedbackFooterTextProvider;
private final CharSequence mWaitDialogTitle;
private final CharSequence mWaitDialogMessage;
private final CharSequence mWaitDialogCancelButtonText;
private final Map<String, String> mHttpHeaders;
private final DoorbellService mDoorbellService;
private final Activity mActivity;
private final Callable<Map<String, Object>> mAdditionalPropertiesProvider;
private final Callable<List<String>> mAdditionalTagsProvider;
private final MaoniDoorbellTransferListener mTransferListener;
/**
* Constructor, with the defaults.
*
* @param activity the calling activity
*/
public MaoniDoorbellListener(final Activity activity) {
this(new Builder(activity));
}
/**
* Constructor, from a Builder instance
* @param builder the builder instance
*/
public MaoniDoorbellListener(Builder builder) {
this.mActivity = builder.activity;
this.mApplicationId = builder.applicationId;
this.mApplicationKey = builder.applicationKey;
this.mFeedbackHeaderTextProvider = builder.feedbackHeaderTextProvider;
this.mFeedbackFooterTextProvider = builder.feedbackFooterTextProvider;
this.mHttpHeaders = builder.httpHeaders;
this.mSkipFilesUpload = builder.skipFilesUpload;
this.mWaitDialogTitle = builder.waitDialogTitle;
this.mWaitDialogMessage = builder.waitDialogMessage;
this.mWaitDialogCancelButtonText = builder.waitDialogCancelButtonText;
this.mAdditionalPropertiesProvider = builder.additionalPropertiesProvider;
this.mAdditionalTagsProvider = builder.additionalTagsProvider;
this.mTransferListener = builder.transferListener;
final OkHttpClient.Builder okHttpClientBilder = new OkHttpClient().newBuilder();
okHttpClientBilder.readTimeout(builder.readTimeout, builder.readTimeoutUnit);
okHttpClientBilder.connectTimeout(builder.connectTimeout, builder.connectTimeoutUnit);
if (builder.debug) {
final HttpLoggingInterceptor interceptor =
new HttpLoggingInterceptor(new HttpLoggingInterceptor.Logger() {
@Override
public void log(String message) {
Log.d(USER_AGENT, message);
}
});
interceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
okHttpClientBilder.addInterceptor(interceptor);
}
this.mDoorbellService = new Retrofit.Builder()
.baseUrl(FEEDBACK_API_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(okHttpClientBilder.build())
.build()
.create(DoorbellService.class);
}
/**
* Method to override, so as to include the email address of the user sending feedback.
*
* @return a valid email address, the address of the user sending feedback
*/
@SuppressWarnings("WeakerAccess")
protected String getUserEmail() {
return null;
}
/**
* Method to override, so as to include the name of the user who is sending feedback.
*
* @return the name of the user who is sending feedback
*/
@SuppressWarnings("WeakerAccess")
protected String getUserName() {
return null;
}
@Override
public void onDismiss() {
//Nothing to do
}
@Override
public boolean onSendButtonClicked(Feedback feedback) {
//Check that device is actually connected to the internet prior to going any further
final ConnectivityManager connMgr = (ConnectivityManager)
mActivity.getSystemService(Context.CONNECTIVITY_SERVICE);
final NetworkInfo networkInfo = connMgr.getActiveNetworkInfo();
if (networkInfo == null || !networkInfo.isConnected()) {
Toast.makeText(mActivity,
"An Internet connection is needed to send feedback.", Toast.LENGTH_SHORT)
.show();
return false;
}
MultiThreadingManager.getFeedbackExecutor().execute(new FeedbackSenderTask(feedback));
return true;
}
/**
* Construct a new Builder
* @param activity the calling activity
* @return a builder
*/
public Builder newBuilder(final Activity activity) {
return new Builder(activity);
}
/**
* Builder for Maoni Doorbell Listener
*/
public static final class Builder {
static final long READ_TIMEOUT_DEFAULT = 10;
static final TimeUnit READ_TIMEOUT_UNIT_DEFAULT = TimeUnit.SECONDS;
static final long CONNECT_TIMEOUT_DEFAULT = 10;
static final TimeUnit CONNECT_TIMEOUT_UNIT_DEFAULT = TimeUnit.SECONDS;
int applicationId;
String applicationKey;
boolean skipFilesUpload;
Map<String, String> httpHeaders;
Callable<CharSequence> feedbackHeaderTextProvider;
Callable<CharSequence> feedbackFooterTextProvider;
final Activity activity;
CharSequence waitDialogTitle;
CharSequence waitDialogMessage;
CharSequence waitDialogCancelButtonText;
boolean debug;
long readTimeout;
TimeUnit readTimeoutUnit;
long connectTimeout;
TimeUnit connectTimeoutUnit;
Callable<Map<String, Object>> additionalPropertiesProvider;
Callable<List<String>> additionalTagsProvider;
MaoniDoorbellTransferListener transferListener;
public Builder(final Activity activity) {
this.activity = activity;
this.debug = false;
this.skipFilesUpload = false;
this.readTimeout = READ_TIMEOUT_DEFAULT;
this.readTimeoutUnit = READ_TIMEOUT_UNIT_DEFAULT;
this.connectTimeout = CONNECT_TIMEOUT_DEFAULT;
this.connectTimeoutUnit = CONNECT_TIMEOUT_UNIT_DEFAULT;
this.waitDialogTitle = "Please hold on...";
this.waitDialogMessage = "Submitting your feedback...";
this.waitDialogCancelButtonText = "Cancel";
this.httpHeaders = new HashMap<>();
this.httpHeaders.put("Content-Type", "application/json");
this.httpHeaders.put("User-Agent", USER_AGENT);
this.transferListener = null;
}
/**
* @param applicationId your Doorbell Application ID
* @return this builder
*/
public Builder withApplicationId(int applicationId) {
this.applicationId = applicationId;
return this;
}
/**
* @param applicationKey your Doorbell Secret Application Key
* @return this builder
*/
public Builder withApplicationKey(String applicationKey) {
this.applicationKey = applicationKey;
return this;
}
public Builder withFeedbackHeaderText(final String feedbackHeaderText) {
return this.withFeedbackHeaderTextProvider(new Callable<CharSequence>() {
@Override
public CharSequence call() throws Exception {
return feedbackHeaderText;
}
});
}
public Builder withFeedbackFooterText(final String feedbackFooterText) {
return this.withFeedbackFooterTextProvider(new Callable<CharSequence>() {
@Override
public CharSequence call() throws Exception {
return feedbackFooterText;
}
});
}
public Builder withFeedbackHeaderTextProvider(final Callable<CharSequence> feedbackHeaderTextProvider) {
this.feedbackHeaderTextProvider = feedbackHeaderTextProvider;
return this;
}
public Builder withFeedbackFooterTextProvider(final Callable<CharSequence> feedbackFooterTextProvider) {
this.feedbackFooterTextProvider = feedbackFooterTextProvider;
return this;
}
public Builder withWaitDialogCancelButtonText(CharSequence waitDialogCancelButtonText) {
this.waitDialogCancelButtonText = waitDialogCancelButtonText;
return this;
}
public Builder withWaitDialogMessage(CharSequence waitDialogMessage) {
this.waitDialogMessage = waitDialogMessage;
return this;
}
public Builder withWaitDialogTitle(CharSequence waitDialogTitle) {
this.waitDialogTitle = waitDialogTitle;
return this;
}
/**
* Set a flag indicating whether or not to skip file uploads to Doorbell.
* <p>
* This may be useful in cases like:
* <ul>
* <li>you know in advance that your Doorbell account does not uploads capabilities</li>
* <li>and/or you wish to explicitly upload files to another remote service</li>
* </ul>
*
* @param skipFilesUpload a flag indicating whether or not to skip file uploads to Doorbell
* @return this builder
*/
public Builder withSkipFilesUpload(boolean skipFilesUpload) {
this.skipFilesUpload = skipFilesUpload;
return this;
}
public Builder withDebug(boolean debug) {
this.debug = debug;
return this;
}
public Builder withReadTimeout(long readTimeout) {
this.readTimeout = readTimeout;
return this;
}
public Builder withReadTimeoutUnit(TimeUnit readTimeoutUnit) {
this.readTimeoutUnit = readTimeoutUnit;
return this;
}
public Builder withConnectTimeout(long connectTimeout) {
this.connectTimeout = connectTimeout;
return this;
}
public Builder withConnectTimeoutUnit(TimeUnit connectTimeoutUnit) {
this.connectTimeoutUnit = connectTimeoutUnit;
return this;
}
public Builder addHttpHeader(String headerKey, String headerValue) {
if (this.httpHeaders == null) {
this.httpHeaders = new HashMap<>();
}
this.httpHeaders.put(headerKey, headerValue);
return this;
}
public Builder withAdditionalPropertiesToSend(final Map<String, Object> additionalProperties) {
return this.withAdditionalPropertiesProvider(new Callable<Map<String, Object>>() {
@Override
public Map<String, Object> call() throws Exception {
return additionalProperties;
}
});
}
public Builder withAdditionalPropertiesProvider(Callable<Map<String, Object>> additionalPropertiesProvider) {
this.additionalPropertiesProvider = additionalPropertiesProvider;
return this;
}
public Builder withAdditionalTagsToSend(final List<String> additionalTags) {
return this.withAdditionalTagsProvider(new Callable<List<String>>() {
@Override
public List<String> call() throws Exception {
return additionalTags;
}
});
}
public Builder withAdditionalTagsProvider(Callable<List<String>> additionalTagsProvider) {
this.additionalTagsProvider = additionalTagsProvider;
return this;
}
public Builder withTransferListener(MaoniDoorbellTransferListener transferListener) {
this.transferListener = transferListener;
return this;
}
public MaoniDoorbellListener build() {
return new MaoniDoorbellListener(this);
}
}
private class FeedbackSenderTask
extends UiRelatedProgressTask<Throwable, MaoniDoorbellTransferProgress> {
private final Feedback feedback;
private final ProgressDialog alertDialog;
private final Map<String, Object> properties;
private final List<String> tags;
FeedbackSenderTask(Feedback feedback) {
this.feedback = feedback;
alertDialog = new ProgressDialog(mActivity);
alertDialog.setTitle(mWaitDialogTitle);
alertDialog.setMessage(mWaitDialogMessage);
alertDialog.setIndeterminate(false);
alertDialog.setCancelable(false);
alertDialog.setCanceledOnTouchOutside(false);
alertDialog.setMax(100);
alertDialog.setButton(DialogInterface.BUTTON_NEGATIVE, mWaitDialogCancelButtonText,
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
FeedbackSenderTask.this.cancel();
}
});
alertDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
mActivity.runOnUiThread(new Runnable() {
@Override
public void run() {
alertDialog.show();
}
});
this.properties = new HashMap<>();
this.tags = new ArrayList<>();
}
@Override
protected void onProgressUpdate(MaoniDoorbellTransferProgress transferProgress) {
if (MaoniDoorbellListener.this.mTransferListener != null) {
MaoniDoorbellListener.this.mTransferListener.onProgressUpdate(transferProgress);
}
switch (transferProgress) {
case UPLOADING_FEEDBACK_CONTENT:
alertDialog.setProgress(50);
break;
case COMPLETED:
case ERROR:
alertDialog.setProgress(100);
sUiHandler.postDelayed(alertDialog::cancel, 1000);
break;
}
}
@Override
protected Throwable doWork() {
publishProgress(STARTED);
final boolean includeScreenshot = feedback.includeScreenshot;
final boolean includeLogs = feedback.includeLogs;
try {
final Response<ResponseBody> openResponse = mDoorbellService
.openApplication(mHttpHeaders, mApplicationId, mApplicationKey)
.execute();
if (openResponse.code() != 201) {
throw new IllegalStateException("Cannot open Doorbell application: " +
openResponse.message());
}
if (mAdditionalPropertiesProvider != null) {
final Map<String, Object> map = mAdditionalPropertiesProvider.call();
if (map != null) {
properties.putAll(map);
}
}
if (mAdditionalTagsProvider != null) {
List<String> list = mAdditionalTagsProvider.call();
if (list != null) {
tags.addAll(list);
}
}
//Add device info retrieved from the Feedback object
final DeviceInfo deviceInfo = feedback.deviceInfo;
if (deviceInfo != null) {
properties.putAll(feedback.getDeviceAndAppInfoAsHumanReadableMap());
}
final List<String> attachmentsIds = new ArrayList<>();
//1. Upload file captures
if (!mSkipFilesUpload) {
final Map<String, String> multipartUploadHeaders = new HashMap<>(mHttpHeaders);
multipartUploadHeaders.remove("Content-Type");
boolean screenshotUploadProgressPublished = false;
if (includeScreenshot) {
publishProgress(UPLOADING_FILES_CAPTURED);
screenshotUploadProgressPublished = true;
final Response<String[]> uploadScreenshotResponse = mDoorbellService
.uploadScreenshot(multipartUploadHeaders, mApplicationId, mApplicationKey,
RequestBody.create(
MediaType.parse(DoorbellService.MULTIPART_FORM_DATA),
feedback.screenshotFile)).execute();
switch (uploadScreenshotResponse.code()) {
case 201:
//Great
break;
case 400:
throw new IllegalArgumentException(uploadScreenshotResponse.message());
default:
throw new IllegalStateException(uploadScreenshotResponse.message());
}
attachmentsIds.addAll(Arrays.asList(uploadScreenshotResponse.body()));
}
if (includeLogs) {
if (!screenshotUploadProgressPublished) {
publishProgress(UPLOADING_FILES_CAPTURED);
}
final Response<String[]> uploadLogsResponse = mDoorbellService
.uploadLogs(multipartUploadHeaders, mApplicationId, mApplicationKey,
RequestBody.create(
MediaType.parse(DoorbellService.MULTIPART_FORM_DATA),
feedback.logsFile))
.execute();
switch (uploadLogsResponse.code()) {
case 201:
//Great
break;
case 400:
throw new IllegalArgumentException(uploadLogsResponse.message());
default:
throw new IllegalStateException(uploadLogsResponse.message());
}
attachmentsIds.addAll(Arrays.asList(uploadLogsResponse.body()));
}
}
//Now send feedback object
publishProgress(UPLOADING_FEEDBACK_CONTENT);
final String userEmail = getUserEmail();
final String userName = getUserName();
final List<Long> attachments = new ArrayList<>();
for (final String attachmentsId : attachmentsIds) {
if (attachmentsId != null) {
attachments.add(Long.valueOf(attachmentsId));
}
}
final DoorbellSubmitRequest doorbellSubmitRequest = new DoorbellSubmitRequest()
.setEmail(userEmail)
.setName(userName)
.setMessage(String.format("%s\n%s\n%s",
nullToEmpty(mFeedbackHeaderTextProvider != null ?
mFeedbackHeaderTextProvider.call() : null),
nullToEmpty(feedback.userComment),
nullToEmpty(mFeedbackFooterTextProvider != null ?
mFeedbackFooterTextProvider.call() : null)))
.setAttachments(attachments)
.setProperties(properties)
.setTags(tags);
final Response<ResponseBody> response = mDoorbellService
.submitFeedbackForm(
mHttpHeaders,
mApplicationId,
mApplicationKey,
doorbellSubmitRequest)
.execute();
if (response.code() != 201) {
throw new IllegalStateException(response.message());
}
publishProgress(COMPLETED);
return null;
} catch (final Exception exception) {
exception.printStackTrace();
publishProgress(ERROR);
if (MaoniDoorbellListener.this.mTransferListener != null) {
MaoniDoorbellListener.this.mTransferListener.onError(exception);
}
return exception;
}
}
@Override
protected void thenDoUiRelatedWork(Throwable throwable) {
if (throwable == null && MaoniDoorbellListener.this.mTransferListener != null) {
MaoniDoorbellListener.this.mTransferListener
.thenDoUiRelatedWorkOnSuccessfulTransfer(this.feedback);
}
}
}
private static String nullToEmpty(final CharSequence str) {
return (TextUtils.isEmpty(str) ? EMPTY_STRING : str.toString());
}
}