COVIDSafe code from version 1.0.16

This commit is contained in:
covidsafe-support 2020-05-08 15:23:03 +10:00
commit b827cf3cce
341 changed files with 28036 additions and 0 deletions

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.atlassian.mobilekit.module.feedback">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application>
<activity
android:name="com.atlassian.mobilekit.module.feedback.FeedbackActivity"
android:windowSoftInputMode="stateUnchanged|adjustResize"
android:exported="false"
android:excludeFromRecents="true" />
</application>
</manifest>

View file

@ -0,0 +1,108 @@
package com.atlassian.mobilekit.module.core;
import android.app.Activity;
import android.app.Application;
import android.os.Bundle;
import org.jetbrains.annotations.NotNull;
import java.lang.ref.WeakReference;
import java.util.concurrent.CopyOnWriteArraySet;
public class ActivityTracker implements Application.ActivityLifecycleCallbacks, UiInfo {
private WeakReference<Activity> activityRef = new WeakReference<>(null);
private boolean isAppVisible = false;
private final CopyOnWriteArraySet<UiInfoListener> listeners = new CopyOnWriteArraySet<>();
public ActivityTracker(Application application) {
application.registerActivityLifecycleCallbacks(this);
}
@Override
public Activity getCurrentActivity() {
Activity currentActivity = activityRef.get();
if (currentActivity != null
&& (currentActivity.isFinishing()
|| currentActivity.isChangingConfigurations())) {
currentActivity = null;
activityRef = new WeakReference<>(null);
}
return currentActivity;
}
@Override
public boolean isAppVisible() {
return isAppVisible;
}
@Override
public void registerListener(UiInfoListener listener) {
listeners.add(listener);
}
@Override
public void unregisterListener(UiInfoListener listener) {
listeners.remove(listener);
}
private void notifyAppVisible() {
isAppVisible = true;
for (UiInfoListener listener : listeners) {
listener.onAppVisible();
}
}
private void notifyAppNotVisible() {
isAppVisible = false;
for (UiInfoListener listener : listeners) {
listener.onAppNotVisible();
}
}
@Override
public void onActivityCreated(@NotNull Activity activity, Bundle bundle) {
}
@Override
public void onActivityStarted(@NotNull Activity activity) {
}
@Override
public void onActivityResumed(@NotNull Activity activity) {
final boolean wasEmpty = (activityRef.get() == null);
activityRef = new WeakReference<>(activity);
if (wasEmpty) {
notifyAppVisible();
}
}
@Override
public void onActivityPaused(@NotNull Activity activity) {
}
@Override
public void onActivityStopped(@NotNull Activity activity) {
if (activityRef.get() == activity) {
activityRef = new WeakReference<>(null);
notifyAppNotVisible();
}
}
@Override
public void onActivitySaveInstanceState(@NotNull Activity activity, @NotNull Bundle bundle) {
}
@Override
public void onActivityDestroyed(@NotNull Activity activity) {
if (activityRef.get() == activity) {
activityRef = new WeakReference<>(null);
}
}
}

View file

@ -0,0 +1,24 @@
package com.atlassian.mobilekit.module.core;
import android.os.Handler;
import android.os.Looper;
public class AndroidUiNotifier implements UiNotifier {
private final Handler uiHandler;
public AndroidUiNotifier() {
uiHandler = new Handler(Looper.getMainLooper());
}
@Override
public void post(Runnable runnable) {
uiHandler.post(runnable);
}
@Override
public void postDelayed(Runnable runnable, int delay) {
uiHandler.postDelayed(runnable, delay);
}
}

View file

@ -0,0 +1,7 @@
package com.atlassian.mobilekit.module.core;
/**
* Synonym interface, only to emphasize its usage.
*/
public interface Command extends Runnable {
}

View file

@ -0,0 +1,144 @@
package com.atlassian.mobilekit.module.core;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.Build;
import android.provider.Settings;
import java.util.Locale;
import java.util.UUID;
public final class DeviceInfo {
private static final String NAMESPACE = "com.atlassian.mobilekit.module.core";
private static final String STORE_NAME = NAMESPACE + ".preferences";
private static final String UUID_KEY = NAMESPACE + ".UUID";
private static final String ANDROID_OS = "Android OS";
private final Context ctx;
private final SharedPreferences store;
// These use lazy initialization
private String uuid;
private String udid;
private String appVersionName;
private int appVersionCode = -1;
public DeviceInfo(Context ctx) {
this.ctx = ctx.getApplicationContext();
store = ctx.getSharedPreferences(STORE_NAME, Context.MODE_PRIVATE);
}
private synchronized String initUdid() {
String androidId = Settings.Secure.getString(ctx.getContentResolver(), Settings.Secure.ANDROID_ID);
if (androidId == null) {
throw new AssertionError("ANDROID_ID setting was null");
}
return androidId;
}
private synchronized String initUuid() {
String uuidStr = store.getString(UUID_KEY, null);
if (uuidStr == null) {
uuidStr = UUID.randomUUID().toString();
SharedPreferences.Editor edit = store.edit();
edit.putString(UUID_KEY, uuidStr);
edit.apply();
}
return uuidStr;
}
public String getUuid() {
if (uuid == null) {
uuid = initUuid();
}
return uuid;
}
public String getUdid() {
if (udid == null) {
udid = initUdid();
}
return udid;
}
public String getAppPkgName() {
return ctx.getPackageName();
}
public String getAppName() {
return ctx.getPackageManager()
.getApplicationLabel(ctx.getApplicationInfo())
.toString();
}
public String getAppVersionName() {
if (appVersionName == null) {
PackageInfo pInfo = getPackageInfo();
appVersionName = pInfo.versionName;
}
return appVersionName;
}
public int getAppVersionCode() {
if (appVersionCode == -1) {
PackageInfo pInfo = getPackageInfo();
appVersionCode = pInfo.versionCode;
}
return appVersionCode;
}
public String getSystemVersion() {
return Build.VERSION.RELEASE;
}
public String getSystemName() {
return ANDROID_OS;
}
public String getDeviceName() {
return Build.DEVICE;
}
public String getModel() {
return Build.MODEL;
}
public String getLanguage() {
return Locale.getDefault().getDisplayLanguage();
}
public String getLocale() {
Locale locale = Locale.getDefault();
return locale.getLanguage() + "_" + locale.getCountry();
}
public boolean hasConnectivity() {
ConnectivityManager cMgr = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE);
if (cMgr == null) {
return false;
}
NetworkInfo nwInfo = cMgr.getActiveNetworkInfo();
return nwInfo != null && nwInfo.isConnected();
}
private PackageInfo getPackageInfo() {
try {
return ctx.getPackageManager().getPackageInfo(ctx.getPackageName(), 0);
} catch (PackageManager.NameNotFoundException e) {
throw new RuntimeException(e);
}
}
}

View file

@ -0,0 +1,32 @@
package com.atlassian.mobilekit.module.core;
import androidx.appcompat.app.AppCompatActivity;
public class FeedbackBaseActivity extends AppCompatActivity {
private boolean isPaused;
private long pausedAt;
@Override
protected void onPause() {
super.onPause();
isPaused = true;
pausedAt = System.currentTimeMillis();
}
@Override
protected void onResume() {
super.onResume();
isPaused = false;
pausedAt = 0;
}
protected boolean isPaused() {
return isPaused;
}
protected long getPausedDuration() {
return System.currentTimeMillis() - pausedAt;
}
}

View file

@ -0,0 +1,27 @@
package com.atlassian.mobilekit.module.core;
import androidx.annotation.VisibleForTesting;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public final class JobQueue {
private final ExecutorService executor;
public JobQueue() {
executor = Executors.newFixedThreadPool(5);
}
public final void enqueue(Runnable r) {
executor.execute(r);
}
@VisibleForTesting
public Executor getExecutor() {
return executor;
}
}

View file

@ -0,0 +1,97 @@
package com.atlassian.mobilekit.module.core;
import androidx.appcompat.widget.AppCompatTextView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import com.atlassian.mobilekit.module.feedback.R;
public class MobileKitDialogViewBuilder {
private final LayoutInflater inflater;
private final ViewGroup container;
private int titleResId;
private int msgResId;
private int posBtnResId;
private int negBtnResId;
private View.OnClickListener posClickListener;
private View.OnClickListener negClickListener;
public MobileKitDialogViewBuilder(LayoutInflater inflater, ViewGroup container) {
this.inflater = inflater;
this.container = container;
}
public MobileKitDialogViewBuilder title(int titleResId) {
this.titleResId = titleResId;
return this;
}
public MobileKitDialogViewBuilder message(int msgResId) {
this.msgResId = msgResId;
return this;
}
public MobileKitDialogViewBuilder positiveButton(
int posBtnResId, View.OnClickListener onClickListener) {
this.posBtnResId = posBtnResId;
posClickListener = onClickListener;
return this;
}
public MobileKitDialogViewBuilder negativeButton(
int negBtnResId, View.OnClickListener onClickListener) {
this.negBtnResId = negBtnResId;
negClickListener = onClickListener;
return this;
}
public View build() {
View dialogView = inflater.inflate(R.layout.mk_feedback_dialog_container, container, false);
FrameLayout frameLayout = (FrameLayout) dialogView.findViewById(R.id.dialog_container);
inflater.inflate(R.layout.mk_feedback_dialog_content, frameLayout);
final AppCompatTextView titleView = (AppCompatTextView) dialogView.findViewById(R.id.title);
if (titleResId == 0) {
titleView.setVisibility(View.GONE);
} else {
titleView.setText(titleResId);
}
final AppCompatTextView msgView = (AppCompatTextView) dialogView.findViewById(R.id.message);
if (msgResId == 0) {
msgView.setVisibility(View.GONE);
} else {
msgView.setText(msgResId);
}
final Button posBtn = (Button) dialogView.findViewById(R.id.positive_btn);
if (posBtnResId == 0) {
posBtn.setVisibility(View.GONE);
} else {
posBtn.setText(posBtnResId);
posBtn.setOnClickListener(posClickListener);
}
final Button negBtn = (Button) dialogView.findViewById(R.id.negative_btn);
if (negBtnResId == 0) {
negBtn.setVisibility(View.GONE);
} else {
negBtn.setText(negBtnResId);
negBtn.setOnClickListener(negClickListener);
}
return dialogView;
}
}

View file

@ -0,0 +1,6 @@
package com.atlassian.mobilekit.module.core;
public interface Receiver<T> {
void receive(T data);
}

View file

@ -0,0 +1,16 @@
package com.atlassian.mobilekit.module.core;
import android.app.Activity;
public interface UiInfo {
Activity getCurrentActivity();
boolean isAppVisible();
void registerListener(UiInfoListener listener);
void unregisterListener(UiInfoListener listener);
}

View file

@ -0,0 +1,10 @@
package com.atlassian.mobilekit.module.core;
public interface UiInfoListener {
void onAppVisible();
void onAppNotVisible();
}

View file

@ -0,0 +1,9 @@
package com.atlassian.mobilekit.module.core;
public interface UiNotifier {
void post(Runnable runnable);
void postDelayed(Runnable runnable, int delay);
}

View file

@ -0,0 +1,10 @@
package com.atlassian.mobilekit.module.core;
/**
* Notifications to this receiver will be delivered on Ui/Main Thread
*
* @param <T>
*/
public interface UiReceiver<T> extends Receiver<T> {
}

View file

@ -0,0 +1,23 @@
package com.atlassian.mobilekit.module.core.utils;
import android.text.TextUtils;
/**
* This class exists as a workaround for Unit Test issues where TextUtils cannot be mocked
* Refer: http://tools.android.com/tech-docs/unit-testing-support
*/
public final class StringUtils {
public static final String EOL = "\n";
private static final String ELLIPSIS = "\u2026";
private static final int ELLIPSIS_LEN = ELLIPSIS.length();
private StringUtils() {
}
public static String ellipsize(String input, int maxLen) {
return (TextUtils.isEmpty(input) || input.length() <= (maxLen + ELLIPSIS_LEN))
? input
: input.substring(0, maxLen) + ELLIPSIS;
}
}

View file

@ -0,0 +1,22 @@
package com.atlassian.mobilekit.module.core.utils;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import static android.content.Context.INPUT_METHOD_SERVICE;
/**
* Utils to interact with Android system APIs.
*/
public class SystemUtils {
/**
* Hides soft keyboard
*/
public static void hideSoftKeyboard(View target) {
InputMethodManager inputMethodManager = (InputMethodManager) target.getContext().getSystemService(INPUT_METHOD_SERVICE);
if (inputMethodManager != null) {
inputMethodManager.hideSoftInputFromWindow(target.getWindowToken(), 0);
}
}
}

View file

@ -0,0 +1,239 @@
package com.atlassian.mobilekit.module.feedback;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.Patterns;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.WindowManager;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.Toast;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Lifecycle;
import com.atlassian.mobilekit.module.core.DeviceInfo;
import com.atlassian.mobilekit.module.core.utils.SystemUtils;
import com.atlassian.mobilekit.module.feedback.commands.Result;
public class FeedbackActivity extends AppCompatActivity
implements ProgressDialogActions, FinishAction, SendFeedbackListener {
private EditText feedbackEt;
private EditText feedbackEmailEt;
private MenuItem sendMenuItem = null;
private DeviceInfo deviceInfo = null;
public static Intent getIntent(Context src) {
Intent intent = new Intent(src, FeedbackActivity.class);
if (!(src instanceof Activity)) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
return intent;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// TO make sure scroll works with editTexts
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
setContentView(R.layout.activity_feedback);
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
}
toolbar.setNavigationOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
finish();
}
});
feedbackEt = (EditText) findViewById(R.id.feedbackIssueDescriptionEditText);
feedbackEt.addTextChangedListener(new TextWatcherAdapter() {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
updateSendButtonState();
}
});
feedbackEmailEt = (EditText) findViewById(R.id.feedbackIssueEmailEditText);
feedbackEmailEt.addTextChangedListener(new TextWatcherAdapter() {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
updateSendButtonState();
}
});
feedbackEmailEt.setOnEditorActionListener((v, actionId, event) -> {
onOptionsItemSelected(sendMenuItem);
return true;
});
View immediateParentView = findViewById(R.id.feedback_content_parent);
if (immediateParentView != null) {
immediateParentView.setOnClickListener(view -> {
// Show keyboard when user clicks on
// Large white area on the screen beside the screenshot
focusOnFeedbackEditText();
});
}
View rootView = findViewById(android.R.id.content);
if (rootView != null) {
rootView.setOnClickListener(view -> {
// Show keyboard when user clicks on
// Large white area on the screen below the screenshot
focusOnFeedbackEditText();
});
}
if (null == savedInstanceState) {
focusOnFeedbackEditText();
}
deviceInfo = new DeviceInfo(getApplicationContext());
FeedbackModule.registerSendFeedbackListener(this);
}
@Override
protected void onDestroy() {
super.onDestroy();
FeedbackModule.unregisterSendFeedbackListener(this);
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.menu_feedback, menu);
sendMenuItem = menu.findItem(R.id.action_send);
sendMenuItem.setEnabled(false);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId();
if (id == R.id.action_send) {
String msg = feedbackEt.getText().toString().trim();
if (TextUtils.isEmpty(msg)) {
Toast.makeText(this, R.string.mk_fb_feedback_empty, Toast.LENGTH_SHORT).show();
return true;
}
String email = feedbackEmailEt.getText().toString().trim();
if (TextUtils.isEmpty(email) || !Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
Toast.makeText(this, R.string.mk_fb_invalid_email_address, Toast.LENGTH_SHORT).show();
return true;
}
if (!deviceInfo.hasConnectivity()) {
Toast.makeText(this, R.string.mk_fb_device_offline, Toast.LENGTH_SHORT).show();
return true;
}
sendMenuItem = item;
SystemUtils.hideSoftKeyboard(feedbackEt);
SystemUtils.hideSoftKeyboard(feedbackEmailEt);
sendFeedback(msg, email);
return true;
}
return super.onOptionsItemSelected(item);
}
private void updateSendButtonState() {
String feedback = feedbackEt.getText().toString();
String email = feedbackEmailEt.getText().toString();
if (!TextUtils.isEmpty(feedback) && Patterns.EMAIL_ADDRESS.matcher(email).matches() && sendMenuItem != null) {
sendMenuItem.setEnabled(true);
} else if (sendMenuItem != null) {
sendMenuItem.setEnabled(false);
}
}
public void showProgressDialog() {
final ProgressDialogFragment progressDialog = new ProgressDialogFragment();
progressDialog.show(getSupportFragmentManager(), ProgressDialogFragment.class.getSimpleName());
}
public void dismissProgressDialog() {
Fragment dialogFragment = getSupportFragmentManager().findFragmentByTag(ProgressDialogFragment.class.getSimpleName());
if (dialogFragment != null
&& dialogFragment instanceof ProgressDialogFragment) {
((ProgressDialogFragment) dialogFragment).dismiss();
}
}
@Override
public void doFinish() {
finish();
}
private void focusOnFeedbackEditText() {
feedbackEt.requestFocus();
showKeyboard();
}
private void sendFeedback(final String msg, final String email) {
FeedbackModule.sendFeedback(msg, email);
showProgressDialog();
}
@Override
public void onSendCompleted(Result result) {
if (Result.SUCCESS == result) {
// Don't allow any more changes
if (sendMenuItem != null) {
sendMenuItem.setEnabled(false);
}
feedbackEt.setEnabled(false);
}
boolean isPaused = !getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED);
if (isPaused) {
finish(); // Cannot show any notification to user. So just finish.
}
}
private void showKeyboard() {
feedbackEt.postDelayed(new Runnable() {
@Override
public void run() {
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
imm.showSoftInput(feedbackEt, InputMethodManager.SHOW_IMPLICIT);
}
}
}, 300);
}
private abstract class TextWatcherAdapter implements TextWatcher {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
// unused
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
// unused
}
@Override
public void afterTextChanged(Editable s) {
// unused
}
}
}

View file

@ -0,0 +1,214 @@
package com.atlassian.mobilekit.module.feedback;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
import androidx.annotation.NonNull;
import com.atlassian.mobilekit.module.core.Command;
import com.atlassian.mobilekit.module.core.DeviceInfo;
import com.atlassian.mobilekit.module.core.JobQueue;
import com.atlassian.mobilekit.module.core.Receiver;
import com.atlassian.mobilekit.module.core.UiInfo;
import com.atlassian.mobilekit.module.core.UiInfoListener;
import com.atlassian.mobilekit.module.core.UiNotifier;
import com.atlassian.mobilekit.module.feedback.commands.Result;
import com.atlassian.mobilekit.module.feedback.commands.SendFeedbackCommand;
import com.atlassian.mobilekit.module.feedback.model.CreateIssueRequest;
import com.atlassian.mobilekit.module.feedback.model.FeedbackConfig;
import com.atlassian.mobilekit.module.feedback.network.BaseApiParams;
import com.atlassian.mobilekit.module.feedback.network.JmcRestClient;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Commands supported by this client
* Show Feedback
* Send Feedback
*/
class FeedbackClient implements Receiver<FeedbackConfig>, UiInfoListener {
private static final String LOG_TAG = FeedbackClient.class.getSimpleName();
private static final String PROTOCOL_HTTPS = "https://";
private static final Set<Class<?>> IGNORE_ACTIVITIES = new HashSet<>();
static {
IGNORE_ACTIVITIES.add(FeedbackActivity.class);
}
private final JmcRestClient restClient;
private final JobQueue jobQueue;
private final DeviceInfo deviceInfo;
private final Map<String, String> baseQueryMap = new HashMap<>();
private final UiNotifier uiNotifier;
private final UiInfo uiInfo;
private final FeedbackSettings settings;
private final AtomicInteger notificationViewId = new AtomicInteger(0);
private final CopyOnWriteArraySet<FeedbackNotificationListener> notificationListeners = new CopyOnWriteArraySet<>();
private final CopyOnWriteArraySet<SendFeedbackListener> sendFeedbackListeners = new CopyOnWriteArraySet<>();
private FeedbackDataProvider feedbackDataProvider;
private FeedbackConfig feedbackConfig;
FeedbackClient(@NonNull JmcRestClient restClient,
@NonNull DeviceInfo deviceInfo,
@NonNull JobQueue jobQueue,
@NonNull UiNotifier uiNotifier,
@NonNull UiInfo uiInfo,
@NonNull FeedbackSettings settings) {
this.restClient = restClient;
this.jobQueue = jobQueue;
this.deviceInfo = deviceInfo;
this.uiNotifier = uiNotifier;
this.uiInfo = uiInfo;
this.settings = settings;
init();
}
private void init() {
uiInfo.registerListener(this);
}
@Override
public void receive(FeedbackConfig data) {
feedbackConfig = data;
restClient.init(PROTOCOL_HTTPS, data.getHost());
baseQueryMap.put(BaseApiParams.API_KEY, data.getApiKey());
baseQueryMap.put(BaseApiParams.PROJECT, data.getProjectKey());
}
final void sendFeedback(String message, String email) {
final CreateIssueRequest.Builder requestBuilder =
new CreateIssueRequest.Builder()
.summary(message)
.description(message)
.isCrash(false)
.udid(deviceInfo.getUdid())
.uuid(deviceInfo.getUuid())
.appName(deviceInfo.getAppName())
.appId(deviceInfo.getAppPkgName())
.systemName(deviceInfo.getSystemName())
.deviceName(deviceInfo.getDeviceName())
.language(deviceInfo.getLanguage())
.components(Arrays.asList(feedbackConfig.getComponents()));
setFeedbackDataProvider(new FeedbackDataProvider() {
@Override
public String getAdditionalDescription() {
return null;
}
@Override
public JiraIssueType getIssueType() {
return JiraIssueType.SUPPORT;
}
@Override
public Map<String, Object> getCustomFieldsData() {
HashMap<String, Object> map = new HashMap<>();
map.put("E-mail", email);
map.put("OS version", deviceInfo.getSystemVersion());
map.put("App version", deviceInfo.getAppVersionName());
map.put("Phone model", deviceInfo.getModel());
return map;
}
});
final Command cmd = new SendFeedbackCommand(
baseQueryMap, requestBuilder, feedbackDataProvider,
restClient,
new SnackbarReceiver(uiInfo, uiNotifier, message, email),
uiNotifier);
jobQueue.enqueue(cmd);
}
@Override
public void onAppVisible() {
}
@Override
public void onAppNotVisible() {
}
final void showFeedback() {
final Activity curActivity = uiInfo.getCurrentActivity();
if (curActivity == null) {
Log.e(LOG_TAG, "No usable current activity. Abort Feedback.");
return;
} else if (IGNORE_ACTIVITIES.contains(curActivity.getClass())) {
Log.e(LOG_TAG, "User is already in Feedback flow. Abort.");
return;
}
final Context appCtx = curActivity.getApplicationContext();
uiNotifier.post(new Runnable() {
@Override
public void run() {
launchFeedbackScreen(curActivity, appCtx);
}
});
}
private void launchFeedbackScreen(Activity activity, Context appCtx) {
final Context useCtx = activity.isFinishing() || activity.isChangingConfigurations()
? appCtx : activity;
Intent intent = FeedbackActivity.getIntent(useCtx);
useCtx.startActivity(intent);
}
private void setFeedbackDataProvider(FeedbackDataProvider feedbackDataProvider) {
this.feedbackDataProvider = feedbackDataProvider;
}
final int getNotificationViewId() {
return notificationViewId.get();
}
final void registerSendFeedbackListener(SendFeedbackListener listener) {
sendFeedbackListeners.add(listener);
}
final void unregisterSendFeedbackListener(SendFeedbackListener listener) {
sendFeedbackListeners.remove(listener);
}
final void notifySendCompleted(Result result) {
for (SendFeedbackListener listener : sendFeedbackListeners) {
listener.onSendCompleted(result);
}
}
final void notificationStarted() {
for (FeedbackNotificationListener fnl : notificationListeners) {
fnl.onNotificationStarted();
}
}
final void notificationDismissed() {
for (FeedbackNotificationListener fnl : notificationListeners) {
fnl.onNotificationDismissed();
}
}
final void setEnableDialogDisplayed() {
settings.setEnableDialogDisplayed();
}
}

View file

@ -0,0 +1,34 @@
package com.atlassian.mobilekit.module.feedback;
import java.util.Map;
import androidx.annotation.Nullable;
/**
* This is used to provide more information which will be used when creating the feedback JIRA issue.
*/
public interface FeedbackDataProvider {
/**
* This string will be appended to the description of Feedback JIRA issue.
* It may contain standard wiki markup that is accepted by the JIRA Instance in description field.
* Encoding supported: UTF-8
* @return
*/
String getAdditionalDescription();
/**
* See {@link JiraIssueType} for available options.
* If this returns null, then the library will default to {@link JiraIssueType#TASK}
* An admin must pre-configure the JIRA Project to accept this type.
* If not, resultant issue will be of default type as per the project.
* @return
*/
JiraIssueType getIssueType();
/**
* This data will be passed to the feedback client to create custom fields in the Jira issue
* @return map of Jira field name and respective values
*/
@Nullable
Map<String, Object> getCustomFieldsData();
}

View file

@ -0,0 +1,103 @@
package com.atlassian.mobilekit.module.feedback;
import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import com.atlassian.mobilekit.module.core.ActivityTracker;
import com.atlassian.mobilekit.module.core.AndroidUiNotifier;
import com.atlassian.mobilekit.module.core.Command;
import com.atlassian.mobilekit.module.core.DeviceInfo;
import com.atlassian.mobilekit.module.core.JobQueue;
import com.atlassian.mobilekit.module.core.UiInfo;
import com.atlassian.mobilekit.module.core.UiNotifier;
import com.atlassian.mobilekit.module.feedback.commands.LoadFeedbackConfigCommand;
import com.atlassian.mobilekit.module.feedback.commands.Result;
import com.atlassian.mobilekit.module.feedback.network.JmcRestClient;
public final class FeedbackModule {
private static final String NAMESPACE = "com.atlassian.mobilekit.module.feedback";
private static final String STORE_NAME = NAMESPACE + ".preferences";
private static FeedbackClient feedbackClient = null;
private static JobQueue jobQueue = null;
private static UiInfo activityTracker = null;
private static UiNotifier androidUiNotifier = new AndroidUiNotifier();
private FeedbackModule() {
throw new AssertionError("Instances of this class are not allowed.");
}
/**
* Initializes using Application object
*
* @param application
*/
public static void init(@NonNull Application application) {
// Build a Feedback Client here and _start_ its initialization.
// Initialization will happen asynchronously in a background thread.
if (feedbackClient == null) {
jobQueue = new JobQueue();
activityTracker = new ActivityTracker(application);
final SharedPreferences store = application.getSharedPreferences(STORE_NAME, Context.MODE_PRIVATE);
feedbackClient = new FeedbackClient(
new JmcRestClient(),
new DeviceInfo(application.getApplicationContext()),
jobQueue,
androidUiNotifier,
activityTracker,
new FeedbackSettings(store));
}
Command loadConfigCommand = new LoadFeedbackConfigCommand(
application.getApplicationContext(),
feedbackClient, androidUiNotifier);
jobQueue.enqueue(loadConfigCommand);
}
/**
* Displays a screen to prompt user for feedback
*/
public static void showFeedbackScreen() {
feedbackClient.showFeedback();
}
static void notificationStarted() {
feedbackClient.notificationStarted();
}
static void notificationDismissed() {
feedbackClient.notificationDismissed();
}
static int getNotificationViewId() {
return feedbackClient.getNotificationViewId();
}
static void sendFeedback(@NonNull String message, @NonNull String email) {
feedbackClient.sendFeedback(message, email);
}
static void setEnableDialogDisplayed() {
feedbackClient.setEnableDialogDisplayed();
}
static void registerSendFeedbackListener(SendFeedbackListener listener) {
feedbackClient.registerSendFeedbackListener(listener);
}
static void unregisterSendFeedbackListener(SendFeedbackListener listener) {
feedbackClient.unregisterSendFeedbackListener(listener);
}
static void notifySendCompleted(Result result) {
feedbackClient.notifySendCompleted(result);
}
}

View file

@ -0,0 +1,12 @@
package com.atlassian.mobilekit.module.feedback;
/**
* This listener is notified when Feedback success/error prompts are displayed to the user. <br/>
* These api are guaranteed to be invoked on the Main thread.
*/
public interface FeedbackNotificationListener {
void onNotificationStarted();
void onNotificationDismissed();
}

View file

@ -0,0 +1,20 @@
package com.atlassian.mobilekit.module.feedback;
import android.content.SharedPreferences;
class FeedbackSettings {
private static final String KEY_ENABLE_DIALOG_SHOWN = "enable_dialog_shown";
private final SharedPreferences store;
FeedbackSettings(SharedPreferences store) {
this.store = store;
}
final void setEnableDialogDisplayed() {
SharedPreferences.Editor editor = store.edit();
editor.putBoolean(KEY_ENABLE_DIALOG_SHOWN, true);
editor.apply();
}
}

View file

@ -0,0 +1,6 @@
package com.atlassian.mobilekit.module.feedback;
public interface FinishAction {
void doFinish();
}

View file

@ -0,0 +1,22 @@
package com.atlassian.mobilekit.module.feedback;
public enum JiraIssueType {
BUG("Bug"),
EPIC("Epic"),
IMPROVEMENT("Improvement"),
STORY("Story"),
SUPPORT("Support"),
TASK("Task");
private String type;
JiraIssueType(String type) {
this.type = type;
}
@Override
public String toString() {
return type;
}
}

View file

@ -0,0 +1,8 @@
package com.atlassian.mobilekit.module.feedback;
public interface ProgressDialogActions {
void showProgressDialog();
void dismissProgressDialog();
}

View file

@ -0,0 +1,27 @@
package com.atlassian.mobilekit.module.feedback;
import android.app.Dialog;
import android.app.ProgressDialog;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatDialogFragment;
public class ProgressDialogFragment extends AppCompatDialogFragment {
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setCancelable(false);
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
ProgressDialog dialog = new ProgressDialog(getActivity(), getTheme());
dialog.setTitle(null);
dialog.setMessage(getString(R.string.mk_fb_sending));
dialog.setIndeterminate(true);
dialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
return dialog;
}
}

View file

@ -0,0 +1,11 @@
package com.atlassian.mobilekit.module.feedback;
import com.atlassian.mobilekit.module.feedback.commands.Result;
/**
* This listener is notified when Feedback sending completes successfully or with an error
*/
public interface SendFeedbackListener {
void onSendCompleted(Result result);
}

View file

@ -0,0 +1,46 @@
package com.atlassian.mobilekit.module.feedback;
import android.app.Activity;
import android.graphics.Color;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.style.ForegroundColorSpan;
import android.view.View;
import com.google.android.material.snackbar.Snackbar;
public class SnackbarBuilder {
private SnackbarBuilder() {
// intentionally empty
}
public static Snackbar build(Activity activity, int resId) {
return Snackbar.make(getNotificationView(activity),
applyColorSpan(activity.getString(resId)),
Snackbar.LENGTH_LONG);
}
private static SpannableStringBuilder applyColorSpan(String txt) {
// Force text color, otherwise it may show up using odd color in the app.
final ForegroundColorSpan whiteSpan = new ForegroundColorSpan(Color.WHITE);
final SpannableStringBuilder spanText = new SpannableStringBuilder(txt);
spanText.setSpan(whiteSpan, 0, spanText.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
return spanText;
}
private static View getNotificationView(Activity activity) {
int id = FeedbackModule.getNotificationViewId();
if (id == 0) {
id = android.R.id.content;
}
View v = activity.findViewById(id);
if (v == null) {
v = activity.findViewById(android.R.id.content);
}
return v;
}
}

View file

@ -0,0 +1,21 @@
package com.atlassian.mobilekit.module.feedback;
import com.google.android.material.snackbar.Snackbar;
public class SnackbarCallback extends Snackbar.Callback {
// Handle multiple onDismissed calls
// Refer: https://code.google.com/p/android/issues/detail?id=214547
private boolean isDismissed = false;
@Override
public void onDismissed(Snackbar snackbar, int event) {
super.onDismissed(snackbar, event);
if (isDismissed) {
return;
}
isDismissed = true;
FeedbackModule.notificationDismissed();
}
}

View file

@ -0,0 +1,150 @@
package com.atlassian.mobilekit.module.feedback;
import android.app.Activity;
import android.view.View;
import androidx.annotation.NonNull;
import com.atlassian.mobilekit.module.core.UiInfo;
import com.atlassian.mobilekit.module.core.UiNotifier;
import com.atlassian.mobilekit.module.core.UiReceiver;
import com.atlassian.mobilekit.module.feedback.commands.Result;
import com.google.android.material.snackbar.Snackbar;
public class SnackbarReceiver implements UiReceiver<Result> {
private final UiInfo uiInfo;
private final UiNotifier uiNotifier;
private final String message;
private final String email;
SnackbarReceiver(@NonNull UiInfo uiInfo, @NonNull UiNotifier uiNotifier,
@NonNull String message, @NonNull String email) {
this.uiInfo = uiInfo;
this.uiNotifier = uiNotifier;
this.message = message;
this.email = email;
}
@Override
public void receive(Result data) {
switch (data) {
case SUCCESS:
showSuccessRunnable.run();
break;
case FAIL:
showFailureRunnable.run();
break;
}
}
private static void showProgressBar(Activity activity) {
if (activity instanceof ProgressDialogActions) {
((ProgressDialogActions) activity).showProgressDialog();
}
}
private static void dismissProgressBar(Activity activity) {
if (activity instanceof ProgressDialogActions) {
((ProgressDialogActions) activity).dismissProgressDialog();
}
}
private static void doFinish(Activity activity) {
if (activity instanceof FinishAction) {
((FinishAction) activity).doFinish();
}
}
private final Runnable showFailureRunnable = new Runnable() {
private int numOfRetries;
@Override
public void run() {
final Activity activity = uiInfo.getCurrentActivity();
if (!uiInfo.isAppVisible()) {
FeedbackModule.notifySendCompleted(Result.FAIL);
return;
} else if (null == activity) {
if (numOfRetries < 3) {
numOfRetries++;
uiNotifier.postDelayed(this, 200);
} else {
FeedbackModule.notifySendCompleted(Result.FAIL);
}
return;
}
// If the code has reached here, then the activity is visible.
FeedbackModule.notifySendCompleted(Result.FAIL);
final Snackbar snackbar = SnackbarBuilder.build(activity, R.string.mk_fb_feedback_failed);
final SnackbarCallback callback = new SnackbarCallback();
snackbar.addCallback(callback);
snackbar.setAction(R.string.mk_fb_retry, new View.OnClickListener() {
@Override
public void onClick(View view) {
FeedbackModule.sendFeedback(message, email);
// We remove the callback here so we don't release the screenshot
snackbar.removeCallback(callback);
showProgressBar(activity);
// We have to handle any notifications that the callback would have.
FeedbackModule.notificationDismissed();
}
});
// Notify Listeners Early of intent
FeedbackModule.notificationStarted();
dismissProgressBar(activity);
snackbar.show();
}
};
private final Runnable showSuccessRunnable = new Runnable() {
private int numOfRetries;
@Override
public void run() {
final Activity activity = uiInfo.getCurrentActivity();
if (!uiInfo.isAppVisible()) {
FeedbackModule.notifySendCompleted(Result.SUCCESS);
return;
} else if (null == activity) {
if (numOfRetries < 3) {
numOfRetries++;
uiNotifier.postDelayed(this, 200);
} else {
FeedbackModule.notifySendCompleted(Result.SUCCESS);
}
return;
}
// If the code has reached here, then the activity is visible.
FeedbackModule.notifySendCompleted(Result.SUCCESS);
final Snackbar snackbar = SnackbarBuilder.build(activity, R.string.mk_fb_feedback_sent);
snackbar.addCallback(new SnackbarCallback() {
@Override
public void onDismissed(Snackbar snackbar, int event) {
super.onDismissed(snackbar, event);
doFinish(activity);
}
});
// Notify Listeners Early of intent
FeedbackModule.notificationStarted();
dismissProgressBar(activity);
snackbar.show();
}
};
}

View file

@ -0,0 +1,45 @@
package com.atlassian.mobilekit.module.feedback.commands;
import android.os.Looper;
import com.atlassian.mobilekit.module.core.Command;
import com.atlassian.mobilekit.module.core.Receiver;
import com.atlassian.mobilekit.module.core.UiNotifier;
import com.atlassian.mobilekit.module.core.UiReceiver;
abstract class AbstractCommand<T> implements Command {
private final Receiver<T> receiver;
private final UiNotifier uiNotifier;
AbstractCommand(Receiver<T> receiver, UiNotifier uiNotifier) {
this.receiver = receiver;
this.uiNotifier = uiNotifier;
}
void updateReceiver(final T data) {
if (receiver == null) {
return;
}
if (!(receiver instanceof UiReceiver) || isMainThread()) {
receiver.receive(data);
} else {
// Post runnable
uiNotifier.post(new Runnable() {
@Override
public void run() {
receiver.receive(data);
}
});
}
}
private boolean isMainThread() {
return Looper.getMainLooper().getThread() == Thread.currentThread();
}
}

View file

@ -0,0 +1,118 @@
package com.atlassian.mobilekit.module.feedback.commands;
import android.content.Context;
import android.text.TextUtils;
import android.util.Log;
import com.atlassian.mobilekit.module.core.Receiver;
import com.atlassian.mobilekit.module.core.UiNotifier;
import com.atlassian.mobilekit.module.feedback.model.FeedbackConfig;
import com.atlassian.mobilekit.module.feedback.R;
import java.net.URI;
import java.net.URISyntaxException;
public final class LoadFeedbackConfigCommand extends AbstractCommand<FeedbackConfig> {
private static final String LOG_TAG = LoadFeedbackConfigCommand.class.getSimpleName();
private final Context context;
public LoadFeedbackConfigCommand(Context ctx,
Receiver<FeedbackConfig> receiver,
UiNotifier uiNotifier) {
super(receiver, uiNotifier);
context = ctx;
}
@Override
public void run() {
FeedbackConfig config = new FeedbackConfig(
context.getString(R.string.mp_feedback_host),
context.getString(R.string.mp_feedback_apikey),
context.getString(R.string.mp_feedback_projectkey),
context.getResources().getStringArray(R.array.mp_feedback_components)
);
String errMsg = errorCheck(config);
if (errMsg != null) {
// This will crash the app, so that developers can correct their code.
throw new IllegalStateException(errMsg);
}
updateReceiver(config);
}
private String errorCheck(FeedbackConfig config) {
StringBuilder errMsg = new StringBuilder();
if (TextUtils.isEmpty(config.getHost())) {
errMsg.append(getConfigEmptyErrMsg(R.string.mp_feedback_host));
} else if (!isValidHost(config.getHost())) {
errMsg.append(getConfigInvalidErrMsg(R.string.mp_feedback_host));
}
if (TextUtils.isEmpty(config.getApiKey())) {
errMsg.append(getConfigEmptyErrMsg(R.string.mp_feedback_apikey));
}
if (TextUtils.isEmpty(config.getProjectKey())) {
errMsg.append(getConfigEmptyErrMsg(R.string.mp_feedback_projectkey));
}
if (errMsg.length() > 0) {
errMsg.append(context.getString(R.string.mk_fb_config_err_help));
return errMsg.toString();
}
return null;
}
private String getConfigEmptyErrMsg(int resId) {
return context.getString(R.string.mk_fb_no_config_property,
context.getResources().getResourceEntryName(resId));
}
private String getConfigInvalidErrMsg(int resId) {
return context.getString(R.string.mk_fb_invalid_config_property,
context.getResources().getResourceEntryName(resId));
}
private boolean isValidHost(String input) {
if (TextUtils.isEmpty(input)) {
return false;
}
if (input.indexOf("/") >= 0) {
return false;
}
try {
URI uri = new URI("scheme://" + input);
String host = uri.getHost();
int port = uri.getPort();
if (TextUtils.isEmpty(host)) {
return false;
}
StringBuilder sb = new StringBuilder();
sb.append(host);
if (port != -1) {
sb.append(":").append(port);
}
return input.equals(sb.toString());
} catch (URISyntaxException use) {
Log.e(LOG_TAG, "URI Validation Failed.", use);
}
return false;
}
}

View file

@ -0,0 +1,6 @@
package com.atlassian.mobilekit.module.feedback.commands;
public enum Result {
SUCCESS, FAIL
}

View file

@ -0,0 +1,109 @@
package com.atlassian.mobilekit.module.feedback.commands;
import android.text.TextUtils;
import android.util.Log;
import com.atlassian.mobilekit.module.core.Receiver;
import com.atlassian.mobilekit.module.core.UiNotifier;
import com.atlassian.mobilekit.module.feedback.FeedbackDataProvider;
import com.atlassian.mobilekit.module.feedback.JiraIssueType;
import com.atlassian.mobilekit.module.feedback.model.CreateIssueRequest;
import com.atlassian.mobilekit.module.feedback.model.CreateIssueResponse;
import com.atlassian.mobilekit.module.feedback.network.JmcRestClient;
import com.google.gson.Gson;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import retrofit2.Call;
import retrofit2.Response;
public final class SendFeedbackCommand extends AbstractCommand<Result> {
private static final String LOG_TAG = SendFeedbackCommand.class.getSimpleName();
private final Map<String, String> queryMap;
private final CreateIssueRequest.Builder requestBuilder;
private final JmcRestClient restClient;
private final FeedbackDataProvider feedbackDataProvider;
public SendFeedbackCommand(Map<String, String> queryMap,
CreateIssueRequest.Builder requestBuilder,
FeedbackDataProvider feedbackDataProvider,
JmcRestClient restClient,
Receiver<Result> receiver,
UiNotifier uiNotifier) {
super(receiver, uiNotifier);
this.queryMap = queryMap;
this.requestBuilder = requestBuilder;
this.feedbackDataProvider = feedbackDataProvider;
this.restClient = restClient;
}
@Override
public void run() {
JiraIssueType issueType = JiraIssueType.TASK;
List<MultipartBody.Part> customFieldsPart = new ArrayList<>();
if (feedbackDataProvider != null) {
final String appendDesc = feedbackDataProvider.getAdditionalDescription();
if (!TextUtils.isEmpty(appendDesc)) {
requestBuilder.appendToDescription(appendDesc);
}
final JiraIssueType typeFromProvider = feedbackDataProvider.getIssueType();
if (typeFromProvider != null) {
issueType = typeFromProvider;
}
final Map<String, Object> customFieldsData = feedbackDataProvider.getCustomFieldsData();
if(customFieldsData != null) {
RequestBody customFieldRequestBody =
RequestBody.create(MediaType.parse("application/json"), new Gson().toJson(customFieldsData));
MultipartBody.Part customFieldPart =
MultipartBody.Part.createFormData("customfields", "customfields.json", customFieldRequestBody);
customFieldsPart.add(customFieldPart);
}
}
requestBuilder.issueType(issueType.toString());
final CreateIssueRequest request = requestBuilder.build();
Call<CreateIssueResponse> call = restClient.getJmcApi().createIssue(queryMap, request, Collections.emptyList(), customFieldsPart);
try {
Response<CreateIssueResponse> response = call.execute();
Log.d(LOG_TAG, String.format("Response code %1$d\nmessage %2$s\nbody %3$s",
response.code(), response.message(), response.body()));
if (response.isSuccessful()) {
CreateIssueResponse body = response.body();
if (body == null) {
Log.e(LOG_TAG, "Bad api response. Empty body.");
} else if (TextUtils.isEmpty(body.getKey())) {
Log.e(LOG_TAG, "Bad api response. Missing Issue Key.");
} else {
Log.d(LOG_TAG, String.format("New Issue Created %s", body.getKey()));
updateReceiver(Result.SUCCESS);
return;
}
}
} catch (IOException ioe) {
Log.e(LOG_TAG,"Failed to create new issue.", ioe);
}
updateReceiver(Result.FAIL);
}
}

View file

@ -0,0 +1,233 @@
package com.atlassian.mobilekit.module.feedback.model;
import android.text.TextUtils;
import com.atlassian.mobilekit.module.core.utils.StringUtils;
import java.util.List;
import androidx.annotation.Keep;
@Keep
public final class CreateIssueRequest {
@Keep
public static class Builder {
private String type;
private String summary;
private String description;
private boolean isCrash;
private String udid;
private String uuid;
private String appName;
private String appId;
private String appVersion;
private String systemVersion;
private String systemName;
private String deviceName;
private String model;
private String language;
private List<String> components;
public Builder() {
}
public Builder issueType(String type) {
this.type = type;
return this;
}
public Builder summary(String summary) {
this.summary = summary;
return this;
}
public Builder description(String description) {
this.description = description;
return this;
}
public Builder appendToDescription(String moreInfo) {
if (!TextUtils.isEmpty(description)) {
StringBuilder sb = new StringBuilder();
sb.append(StringUtils.EOL).append(StringUtils.EOL).append(moreInfo);
description += sb.toString();
}
return this;
}
public Builder isCrash(boolean crash) {
isCrash = crash;
return this;
}
public Builder udid(String udid) {
this.udid = udid;
return this;
}
public Builder uuid(String uuid) {
this.uuid = uuid;
return this;
}
public Builder appName(String appName) {
this.appName = appName;
return this;
}
public Builder appId(String appId) {
this.appId = appId;
return this;
}
public Builder appVersion(String appVersion) {
this.appVersion = appVersion;
return this;
}
public Builder systemVersion(String systemVersion) {
this.systemVersion = systemVersion;
return this;
}
public Builder systemName(String systemName) {
this.systemName = systemName;
return this;
}
public Builder deviceName(String devName) {
this.deviceName = devName;
return this;
}
public Builder model(String model) {
this.model = model;
return this;
}
public Builder language(String language) {
this.language = language;
return this;
}
public Builder components(List<String> components) {
this.components = components;
return this;
}
public CreateIssueRequest build() {
return new CreateIssueRequest(this);
}
}
private static final int MAX_SUMMARY_LENGTH = 240;
private final String type;
private final String summary;
private final String description;
private final boolean isCrash;
private final String udid;
private final String uuid;
private final String appName;
private final String appId;
private final String appVersion;
private final String systemVersion;
private final String systemName;
// This is actually DeviceName.
// *** But this declaration cannot be changed since the Server API expects it to be 'devName'
private final String devName;
private final String model;
private final String language;
private final List<String> components;
public CreateIssueRequest(Builder builder) {
this.type = builder.type;
this.summary = StringUtils.ellipsize(builder.summary, MAX_SUMMARY_LENGTH);
this.description = builder.description;
this.isCrash = builder.isCrash;
this.udid = builder.udid;
this.uuid = builder.uuid;
this.appName = builder.appName;
this.appId = builder.appId;
this.appVersion = builder.appVersion;
this.systemVersion = builder.systemVersion;
this.systemName = builder.systemName;
this.devName = builder.deviceName;
this.model = builder.model;
this.language = builder.language;
this.components = builder.components;
}
public String getType() {
return type;
}
public String getSummary() {
return summary;
}
public String getDescription() {
return description;
}
public boolean isCrash() {
return isCrash;
}
public String getUdid() {
return udid;
}
public String getUuid() {
return uuid;
}
public String getAppName() {
return appName;
}
public String getAppId() {
return appId;
}
public String getAppVersion() {
return appVersion;
}
public String getSystemVersion() {
return systemVersion;
}
public String getSystemName() {
return systemName;
}
public String getDeviceName() {
return devName;
}
public String getModel() {
return model;
}
public String getLanguage() {
return language;
}
public List<String> getComponents() {
return components;
}
}

View file

@ -0,0 +1,52 @@
package com.atlassian.mobilekit.module.feedback.model;
import java.util.List;
import androidx.annotation.Keep;
@Keep
public final class CreateIssueResponse {
private String key;
private String status;
private String summary;
private String description;
private long dateUpdated;
private long dateCreated;
private boolean hasUpdates;
private List<String> comments;
public String getKey() {
return key;
}
public String getStatus() {
return status;
}
public String getSummary() {
return summary;
}
public String getDescription() {
return description;
}
public long getDateUpdated() {
return dateUpdated;
}
public long getDateCreated() {
return dateCreated;
}
public boolean hasUpdates() {
return hasUpdates;
}
public List<String> getComments() {
return comments;
}
}

View file

@ -0,0 +1,33 @@
package com.atlassian.mobilekit.module.feedback.model;
public final class FeedbackConfig {
private final String host;
private final String apiKey;
private final String projectKey;
private final String[] components;
public FeedbackConfig(String host, String apiKey, String projectKey, String[] components) {
this.host = host;
this.apiKey = apiKey;
this.projectKey = projectKey;
this.components = components;
}
public String getHost() {
return host;
}
public String getApiKey() {
return apiKey;
}
public String getProjectKey() {
return projectKey;
}
public String[] getComponents() {
return components;
}
}

View file

@ -0,0 +1,8 @@
package com.atlassian.mobilekit.module.feedback.network;
public interface BaseApiParams {
String API_KEY = "apikey";
String PROJECT = "project";
}

View file

@ -0,0 +1,28 @@
package com.atlassian.mobilekit.module.feedback.network;
import com.atlassian.mobilekit.module.feedback.model.CreateIssueRequest;
import com.atlassian.mobilekit.module.feedback.model.CreateIssueResponse;
import java.util.List;
import java.util.Map;
import androidx.annotation.Keep;
import okhttp3.MultipartBody;
import retrofit2.Call;
import retrofit2.http.Multipart;
import retrofit2.http.POST;
import retrofit2.http.Part;
import retrofit2.http.QueryMap;
public interface JmcApi {
@Multipart
@POST("rest/jconnect/latest/issue/create")
@Keep
Call<CreateIssueResponse> createIssue(
@QueryMap Map<String, String> params,
@Part("issue") CreateIssueRequest request,
@Part List<MultipartBody.Part> screenshotPart,
@Part List<MultipartBody.Part> customFields);
}

View file

@ -0,0 +1,34 @@
package com.atlassian.mobilekit.module.feedback.network;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
public final class JmcRestClient {
private JmcApi jmcApi = null;
public JmcRestClient() {
}
public void init(String protocol, String host) {
final String baseUrl = new StringBuilder()
.append(protocol)
.append(host)
.append("/")
.toString();
final Retrofit retrofit = new Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.build();
jmcApi = retrofit.create(JmcApi.class);
}
public JmcApi getJmcApi() {
return jmcApi;
}
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Disabled background -->
<item android:state_enabled="false"
android:color="@color/N70"/>
<!-- Enabled background -->
<item android:color="@color/B300"/>
</selector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M0,0h24v24h-24z"
android:fillColor="#ffffff"
android:fillAlpha="0.01"/>
<path
android:pathData="M6.9782,19.477C6.9782,20.848 8.6232,21.519 9.5552,20.527L21.5582,7.723C22.5182,6.695 21.8032,5 20.4092,5H3.4962C2.1782,5 1.5052,6.607 2.4182,7.572L6.9782,12.4V19.477ZM8.9712,18.212V11.585L3.8562,6.169C4.1592,6.491 3.9342,7.026 3.4962,7.026H19.4592L8.9712,18.212Z"
android:fillColor="#024B7E"/>
<path
android:pathData="M8.4162,12.902L12.4262,10.952C12.6667,10.8303 12.8505,10.6199 12.9389,10.3653C13.0274,10.1106 13.0135,9.8316 12.9002,9.587C12.8471,9.4665 12.7703,9.3579 12.6743,9.2677C12.5784,9.1775 12.4653,9.1075 12.3418,9.0619C12.2183,9.0163 12.0868,8.9959 11.9553,9.0022C11.8238,9.0084 11.6948,9.041 11.5762,9.098L7.5662,11.048C7.0692,11.29 6.8562,11.901 7.0912,12.412C7.1442,12.5327 7.2209,12.6415 7.3169,12.7319C7.4128,12.8223 7.526,12.8925 7.6497,12.9382C7.7733,12.9839 7.9049,13.0043 8.0366,12.9981C8.1683,12.9918 8.2974,12.9592 8.4162,12.902Z"
android:fillColor="#024B7E"/>
</vector>

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6.9782,19.477C6.9782,20.848 8.6232,21.519 9.5552,20.527L21.5582,7.723C22.5182,6.695 21.8032,5 20.4092,5H3.4962C2.1782,5 1.5052,6.607 2.4182,7.572L6.9782,12.4V19.477ZM8.9712,18.212V11.585L3.8562,6.169C4.1592,6.491 3.9342,7.026 3.4962,7.026H19.4592L8.9712,18.212Z"
android:fillColor="#024B7E"/>
<path
android:pathData="M8.4162,12.902L12.4262,10.952C12.6667,10.8303 12.8505,10.6199 12.9389,10.3653C13.0274,10.1106 13.0135,9.8316 12.9002,9.587C12.8471,9.4665 12.7703,9.3579 12.6743,9.2677C12.5784,9.1775 12.4653,9.1075 12.3418,9.0619C12.2183,9.0163 12.0868,8.9959 11.9553,9.0022C11.8238,9.0084 11.6948,9.041 11.5762,9.098L7.5662,11.048C7.0692,11.29 6.8562,11.901 7.0912,12.412C7.1442,12.5327 7.2209,12.6415 7.3169,12.7319C7.4128,12.8223 7.526,12.8925 7.6497,12.9382C7.7733,12.9839 7.9049,13.0043 8.0366,12.9981C8.1683,12.9918 8.2974,12.9592 8.4162,12.902Z"
android:fillColor="#024B7E"/>
<path
android:pathData="M0,0h24v24h-24z"
android:fillColor="#ffffff"
android:fillAlpha="0.5"/>
</vector>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="true" android:drawable="@drawable/ic_send" />
<item android:state_enabled="false" android:drawable="@drawable/ic_send_disabled" />
</selector>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.atlassian.mobilekit.module.feedback.FeedbackActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@android:color/white"
app:title="@string/report_issue"
app:popupTheme="@style/MobileKit.FeedbackTheme.PopupOverlay" />
</com.google.android.material.appbar.AppBarLayout>
<include layout="@layout/content_feedback" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:context="com.atlassian.mobilekit.module.feedback.FeedbackActivity"
tools:showIn="@layout/activity_feedback">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/feedback_content_parent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
tools:minHeight="200dp">
<TextView
android:id="@+id/feedback_content_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/feedback_found_an_issue_in_the_covidsafe_app"
android:textColor="#141515"
android:textSize="18sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/feedbackIssueDescriptionLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:paddingHorizontal="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/feedback_content_title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/feedbackIssueDescriptionEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/feedback_please_describe_an_issue"
android:inputType="textMultiLine|textAutoComplete|textAutoCorrect|textCapSentences"
android:textSize="16sp"
tools:text="This applications is so awesome, just wanted to express my gratitude" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/feedbackIssueEmailLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:layout_marginTop="16dp"
android:paddingEnd="16dp"
app:hintEnabled="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/feedbackIssueDescriptionLayout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/feedbackIssueEmailEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/feedback_email_address_required"
android:imeOptions="actionSend"
android:inputType="textWebEmailAddress"
android:singleLine="true"
android:textSize="16sp"
android:autofillHints="emailAddress"
tools:text="ylaguta@atlassian.com" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingBottom="32dp"
android:text="@string/feedback_we_may_reach_out_to_you_for_further_details_about_your_feedback"
android:textColor="#788586"
android:textSize="12sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/feedbackIssueEmailLayout" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<FrameLayout
android:id="@+id/dialog_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/positive_btn"
style="@style/MobileKit.DialogButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="@dimen/mk_button_right_margin"
android:layout_marginEnd="@dimen/mk_button_right_margin"
android:layout_below="@id/dialog_container"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:text="" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/negative_btn"
style="@style/MobileKit.DialogButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="@dimen/mk_button_right_margin"
android:layout_marginEnd="@dimen/mk_button_right_margin"
android:layout_below="@id/dialog_container"
android:layout_toLeftOf="@id/positive_btn"
android:layout_toStartOf="@id/positive_btn"
android:layout_alignWithParentIfMissing="true"
android:text=""
/>
</RelativeLayout>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include layout="@layout/mk_feedback_dialog_title" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/MobileKit.Text.Dialog"
android:id="@+id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_below="@id/title"
android:paddingBottom="@dimen/mk_content_bottom_padding"
android:paddingEnd="@dimen/mk_title_horizontal_padding"
android:paddingLeft="@dimen/mk_title_horizontal_padding"
android:paddingRight="@dimen/mk_title_horizontal_padding"
android:paddingStart="@dimen/mk_title_horizontal_padding"
android:text="" />
</RelativeLayout>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignBottom="@id/title"
android:layout_alignLeft="@id/title"
android:layout_alignRight="@id/title"
android:layout_alignTop="@id/title"
android:background="@android:color/transparent"
android:scaleType="centerCrop" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/title"
style="@style/MobileKit.TitleStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:paddingBottom="@dimen/mk_title_bottom_padding"
android:paddingEnd="@dimen/mk_title_horizontal_padding"
android:paddingLeft="@dimen/mk_title_horizontal_padding"
android:paddingRight="@dimen/mk_title_horizontal_padding"
android:paddingStart="@dimen/mk_title_horizontal_padding"
android:paddingTop="@dimen/mk_title_top_padding"
android:text=""
android:textColor="@android:color/black" />
</merge>

View file

@ -0,0 +1,10 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context="com.atlassian.mobilekit.module.feedback.FeedbackActivity">
<item
android:id="@+id/action_send"
android:icon="@drawable/send_selector"
android:title="@string/mk_fb_send"
app:showAsAction="always"/>
</menu>

View file

@ -0,0 +1,6 @@
<resources>
<!-- Example customization of dimensions originally defined in res/values/dimens.xml
(such as screen margins) for screens with more than 820dp of available width. This
would include 7" and 10" devices in landscape (~960dp and ~1280dp respectively). -->
<dimen name="activity_horizontal_margin">64dp</dimen>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="dialog_content_text">#5a697c</color>
<color name="N70">#A5ADBA</color>
<color name="B300">#0065FF</color>
</resources>

View file

@ -0,0 +1,13 @@
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="mk_title_horizontal_padding">24dp</dimen>
<dimen name="mk_title_top_padding">24dp</dimen>
<dimen name="mk_title_bottom_padding">20dp</dimen>
<dimen name="mk_content_bottom_padding">24dp</dimen>
<dimen name="mk_button_right_margin">8dp</dimen>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="font_fontFamily_medium" translatable="false">sans-serif</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources tools:ignore="MissingTranslation" xmlns:tools="http://schemas.android.com/tools">
<string name="mp_feedback_host" />
<string name="mp_feedback_apikey" />
<string name="mp_feedback_projectkey" />
<string-array name="mp_feedback_components">
<item>Android</item>
</string-array>
</resources>

View file

@ -0,0 +1,25 @@
<resources>
<!-- Configurations -->
<string name="mk_fb_no_config_property">\'%s\' configuration property value is empty.</string>
<string name="mk_fb_config_err_help">Cannot create feedback. Please check mp_feedback_config.xml in your application resources.</string>
<string name="mk_fb_invalid_config_property">\'%s\' configuration property value is invalid.</string>
<!-- Internal strings -->
<string name="report_issue">Report an issue</string>
<string name="mk_fb_send">Send</string>
<string name="mk_fb_feedback_empty">Tell us something before sending.</string>
<string name="mk_fb_feedback_sent">Feedback sent</string>
<string name="mk_fb_feedback_failed">Sending feedback failed</string>
<string name="mk_fb_retry">Retry</string>
<string name="mk_fb_device_offline">Your device is offline.</string>
<string name="mk_fb_invalid_email_address">Invalid email address</string>
<string name="mk_fb_sending">Sending…</string>
<string name="feedback_found_an_issue_in_the_covidsafe_app">Found an issue in the COVIDSafe app?</string>
<string name="feedback_please_describe_an_issue">Please describe an issue</string>
<string name="feedback_email_address_required">Email address (Required)</string>
<string name="feedback_we_may_reach_out_to_you_for_further_details_about_your_feedback">We may reach out to you for further details about your feedback.\nYour email address won`t be used for any other purpose.</string>
</resources>

View file

@ -0,0 +1,31 @@
<resources>
<style name="MobileKit.FeedbackTheme.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
<style name="MobileKit.TitleStyle" parent="TextAppearance.AppCompat">
<item name="android:fontFamily">@string/font_fontFamily_medium</item>
<item name="android:textSize">20sp</item>
<item name="android:textAppearance">?android:attr/textAppearanceMedium</item>
</style>
<style name="MobileKit.DialogButton" parent="@style/Widget.AppCompat.Button.ButtonBar.AlertDialog">
<item name="android:textColor">@drawable/feedback_button_text_state</item>
<item name="android:textAppearance">?android:attr/textAppearanceMedium</item>
<item name="android:textAllCaps">true</item>
<item name="android:textStyle">normal</item>
</style>
<style name="MobileKit.Text" parent="TextAppearance.AppCompat">
<!-- Roboto Regular -->
<item name="android:fontFamily">sans-serif</item>
<item name="android:textSize">16sp</item>
</style>
<style name="MobileKit.Text.Dialog" parent="MobileKit.Text">
<!-- Roboto Regular -->
<item name="android:fontFamily">sans-serif</item>
<item name="android:textSize">16sp</item>
<item name="android:textColor">@color/dialog_content_text</item>
</style>
</resources>