├── .gitignore ├── Assets ├── Plugins │ └── Android │ │ └── ANRSupervisor │ │ └── ANRSupervisor.java ├── PreemptANRs.cs └── StoreReportInCosmosDBUsingPlayFab.cs ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # This .gitignore file should be placed at the root of your Unity project directory 2 | # 3 | # Get latest from https://github.com/github/gitignore/blob/master/Unity.gitignore 4 | # 5 | /[Ll]ibrary/ 6 | /[Tt]emp/ 7 | /[Oo]bj/ 8 | /[Bb]uild/ 9 | /[Bb]uilds/ 10 | /[Ll]ogs/ 11 | /[Mm]emoryCaptures/ 12 | 13 | # Asset meta data should only be ignored when the corresponding asset is also ignored 14 | !/[Aa]ssets/**/*.meta 15 | 16 | # Uncomment this line if you wish to ignore the asset store tools plugin 17 | # /[Aa]ssets/AssetStoreTools* 18 | 19 | # Autogenerated Jetbrains Rider plugin 20 | [Aa]ssets/Plugins/Editor/JetBrains* 21 | 22 | # Visual Studio cache directory 23 | .vs/ 24 | 25 | # Gradle cache directory 26 | .gradle/ 27 | 28 | # Autogenerated VS/MD/Consulo solution and project files 29 | ExportedObj/ 30 | .consulo/ 31 | *.csproj 32 | *.unityproj 33 | *.sln 34 | *.suo 35 | *.tmp 36 | *.user 37 | *.userprefs 38 | *.pidb 39 | *.booproj 40 | *.svd 41 | *.pdb 42 | *.mdb 43 | *.opendb 44 | *.VC.db 45 | 46 | # Unity3D generated meta files 47 | *.pidb.meta 48 | *.pdb.meta 49 | *.mdb.meta 50 | 51 | # Unity3D generated file on crash reports 52 | sysinfo.txt 53 | 54 | # Builds 55 | *.apk 56 | *.unitypackage 57 | 58 | # Crashlytics generated file 59 | crashlytics-build.properties 60 | 61 | -------------------------------------------------------------------------------- /Assets/Plugins/Android/ANRSupervisor/ANRSupervisor.java: -------------------------------------------------------------------------------- 1 | //* 2 | import android.os.Handler; 3 | import android.os.Looper; 4 | import com.google.firebase.crashlytics.*; 5 | import java.util.*; 6 | import java.util.concurrent.*; 7 | import java.util.logging.*; 8 | import java.io.ByteArrayOutputStream; 9 | import java.io.PrintStream; 10 | 11 | // A class supervising the UI thread for ANR errors. Use 12 | // {@link #start()} and {@link #stop()} to control 13 | // when the UI thread is supervised 14 | public class ANRSupervisor 15 | { 16 | static ANRSupervisor instance; 17 | 18 | public static Logger logger = Logger.getLogger("ANR"); 19 | public static void Log(Object log) { logger.log(Level.INFO, "com.gamehouse.tx [ANR] " + log); } 20 | 21 | // The {@link ExecutorService} checking the UI thread 22 | private ExecutorService mExecutor; 23 | 24 | // The {@link ANRSupervisorRunnable} running on a separate thread 25 | public final ANRSupervisorRunnable mSupervisorRunnable; 26 | 27 | public ANRSupervisor(Looper looper, int timeoutCheckDuration, int checkInterval) 28 | { 29 | mExecutor = Executors.newSingleThreadExecutor(); 30 | mSupervisorRunnable = new ANRSupervisorRunnable(looper, timeoutCheckDuration, checkInterval); 31 | } 32 | 33 | public static void create() 34 | { 35 | if (instance == null) 36 | { 37 | // Check for misbehaving SDKs on the main thread. 38 | ANRSupervisor.Log("Creating Main Thread Supervisor"); 39 | instance = new ANRSupervisor(Looper.getMainLooper(), 2, 5); 40 | } 41 | 42 | // Why bother? // Check for misbehaving Script code on the Unity thread. 43 | // Why bother? ANRSupervisor.Log("Creating Unity Supervisor"); 44 | // Why bother? ANRSupervisor unitySupervisor = new ANRSupervisor(Looper.myLooper(), 5, 8); 45 | } 46 | 47 | // Starts the supervision 48 | public static synchronized void start() 49 | { 50 | synchronized (instance.mSupervisorRunnable) 51 | { 52 | ANRSupervisor.Log("Starting Supervisor"); 53 | if (instance.mSupervisorRunnable.isStopped()) 54 | { 55 | instance.mExecutor.execute(instance.mSupervisorRunnable); 56 | } 57 | else 58 | { 59 | instance.mSupervisorRunnable.resume(); 60 | } 61 | } 62 | } 63 | 64 | // Stops the supervision. The stop is delayed, so if start() is called right after stop(), 65 | // both methods will have no effect. There will be at least one more ANR check before the supervision is stopped. 66 | public static synchronized void stop() 67 | { 68 | instance.mSupervisorRunnable.stop(); 69 | } 70 | 71 | public static String getReport() 72 | { 73 | if (instance != null && 74 | instance.mSupervisorRunnable != null && 75 | instance.mSupervisorRunnable.mReport != null) 76 | { 77 | String report = instance.mSupervisorRunnable.mReport; 78 | instance.mSupervisorRunnable.mReport = null; 79 | return report; 80 | } 81 | return null; 82 | } 83 | 84 | public static void reportSent() 85 | { 86 | if (instance != null && 87 | instance.mSupervisorRunnable != null) 88 | { 89 | instance.mSupervisorRunnable.mReportSent = true; 90 | } 91 | } 92 | 93 | public static synchronized void generateANROnMainThreadTEST() 94 | { 95 | ANRSupervisor.Log("Creating mutext locked infinite thread"); 96 | new Thread(new Runnable() { 97 | @Override public void run() { 98 | synchronized (instance) { 99 | while (true) { 100 | ANRSupervisor.Log("Sleeping for 60 seconds"); 101 | try { 102 | Thread.sleep(60*1000); 103 | } catch (InterruptedException e) { 104 | e.printStackTrace(); 105 | } 106 | } 107 | } 108 | } 109 | }).start(); 110 | 111 | ANRSupervisor.Log("Running a callback on the main thread that tries to lock the mutex (but can't)"); 112 | new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { 113 | @Override public void run() { 114 | ANRSupervisor.Log("Trying to lock the mutex"); 115 | synchronized (instance) { 116 | ANRSupervisor.Log("This shouldn't happen"); 117 | throw new IllegalStateException(); 118 | } 119 | } 120 | }, 1000); 121 | 122 | ANRSupervisor.Log("End of generateANROnMainThreadTEST"); 123 | } 124 | } 125 | 126 | // A {@link Runnable} testing the UI thread every 5 seconds until {@link #stop()} is called 127 | class ANRSupervisorRunnable implements Runnable 128 | { 129 | // The {@link Handler} to access the UI threads message queue 130 | private Handler mHandler; 131 | 132 | // The stop flag 133 | private boolean mStopped; 134 | 135 | // Flag indicating the stop was performed 136 | private boolean mStopCompleted = true; 137 | 138 | private int mTimeoutCheck; 139 | private int mCheckInterval; 140 | private int mFalsePositiveCheckDelay = 1; 141 | private int mMaxReportSendWaitDuration = 5; 142 | 143 | public boolean mReportSent; 144 | public String mReport; 145 | 146 | public ANRSupervisorRunnable(Looper looper, int timeoutCheckDuration, int checkInterval) 147 | { 148 | ANRSupervisor.Log("Installing ANR Suparvisor on " + looper + " timeout: " + timeoutCheckDuration); 149 | mHandler = new Handler(looper); 150 | mTimeoutCheck = timeoutCheckDuration; 151 | mCheckInterval = checkInterval; 152 | } 153 | 154 | @Override public void run() 155 | { 156 | this.mStopCompleted = false; 157 | 158 | // Loop until stop() was called or thread is interrupted 159 | while (!Thread.interrupted()) 160 | { 161 | try 162 | { 163 | //ANRSupervisor.Log("Sleeping for " + mCheckInterval + " seconds until next test"); 164 | Thread.sleep(mCheckInterval * 1000); 165 | 166 | ANRSupervisor.Log("Check for ANR..."); 167 | 168 | // Create new callback 169 | ANRSupervisorCallback callback = new ANRSupervisorCallback(); 170 | 171 | // Perform test, Handler should run the callback within X seconds 172 | synchronized (callback) 173 | { 174 | this.mHandler.post(callback); 175 | callback.wait(mTimeoutCheck * 1000); 176 | 177 | // Check if called 178 | if (!callback.isCalled()) 179 | { 180 | ANRSupervisor.Log("Thread " + this.mHandler.getLooper() + " DID NOT respond within " + mTimeoutCheck + " seconds"); 181 | 182 | ByteArrayOutputStream bos = new ByteArrayOutputStream(); 183 | PrintStream ps = new PrintStream(bos); 184 | 185 | // Get all stack traces in the system 186 | Map stackTraces = Thread.getAllStackTraces(); 187 | Locale l = Locale.getDefault(); 188 | 189 | String deviceName = ""; 190 | try 191 | { 192 | android.content.ContentResolver cr = com.unity3d.player.UnityPlayer.currentActivity.getApplicationContext().getContentResolver(); 193 | deviceName = android.provider.Settings.Secure.getString(cr, "device_name"); 194 | if (deviceName == null || deviceName.length() <= 0) 195 | { 196 | deviceName = android.provider.Settings.Secure.getString(cr, "bluetooth_name"); 197 | } 198 | } 199 | catch (Exception e) {} 200 | 201 | ps.print(String.format(l, "{\"title\":\"ANR Report\",\"build_version\":\"%s\",\"device\":\"%s\",\"name\":\"%s\",\"callstacks\":[", 202 | String.valueOf(BuildConfig.VERSION_NAME), android.os.Build.FINGERPRINT, deviceName)); 203 | 204 | Thread supervisedThread = this.mHandler.getLooper().getThread(); 205 | boolean isFirstThread = true; 206 | boolean gmsThreadIsBlocked = false; 207 | for (Thread thread : stackTraces.keySet()) 208 | { 209 | boolean isBlocked = thread.getState().equals("BLOCKED"); 210 | if (thread == supervisedThread || 211 | thread.getName().equals("main") || 212 | thread.getName().equals("UnityMain") || 213 | isBlocked) 214 | { 215 | if (isFirstThread) { isFirstThread = false; } else { ps.print(","); } 216 | ps.print(String.format(l, "{\"name\":\"%s\",\"state\":\"%s\"", thread.getName(), thread.getState())); 217 | 218 | if (thread == supervisedThread) 219 | { 220 | ps.print(",\"supervised\":true"); 221 | } 222 | 223 | StackTraceElement[] stack = stackTraces.get(thread); 224 | if (stack.length > 0) 225 | { 226 | ps.print(",\"stack\":["); 227 | boolean isFirstLine = true; 228 | int numStackLines = Math.min(stack.length, 3); 229 | for (int i = 0; i < numStackLines; ++i) 230 | { 231 | if (isFirstLine) { isFirstLine = false; } else { ps.print(","); } 232 | StackTraceElement element = stack[i]; 233 | ps.print(String.format(l, "{\"func\":\"%s.%s\",\"file\":\"%s\",\"line\":%d}", 234 | element.getClassName(), 235 | element.getMethodName(), 236 | element.getFileName(), 237 | element.getLineNumber())); 238 | 239 | if (isBlocked && element.getClassName().contains("gms.ads")) 240 | { 241 | gmsThreadIsBlocked = true; 242 | } 243 | } 244 | ps.print("]"); 245 | } 246 | ps.print("}"); 247 | } 248 | } 249 | ps.print("]}"); 250 | 251 | String report = new String(bos.toByteArray()); 252 | ANRSupervisor.Log(report); 253 | 254 | ANRSupervisor.Log("Sending log to Firebase"); 255 | //FirebaseCrash.report(e); 256 | FirebaseCrashlytics.getInstance().log(report); 257 | 258 | mReportSent = false; 259 | mReport = report; 260 | 261 | ANRSupervisor.Log("Waiting a maximum of " + mMaxReportSendWaitDuration + " seconds to send the log..."); 262 | for (int timePassed = 0; timePassed < mMaxReportSendWaitDuration * 1000; timePassed += 100) 263 | { 264 | if (mReportSent && timePassed >= mFalsePositiveCheckDelay * 1000) 265 | { 266 | break; 267 | } 268 | Thread.sleep(100); 269 | } 270 | 271 | // When we are blocked by GMS.ADS, quit the game. 272 | if (gmsThreadIsBlocked) 273 | { 274 | ANRSupervisor.Log("Checking for false-positive"); 275 | if (!callback.isCalled()) 276 | { 277 | ANRSupervisor.Log("Killing myself"); 278 | // If the supervised thread still did not respond, quit the app. 279 | android.os.Process.killProcess(android.os.Process.myPid()); 280 | 281 | ANRSupervisor.Log("Exiting the app"); 282 | System.exit(0); // SNAFU 283 | } 284 | } 285 | } 286 | else 287 | { 288 | //ANRSupervisor.Log("Thread " + this.mHandler.getLooper() + " responded within " + mTimeoutCheck + " seconds"); 289 | } 290 | } 291 | 292 | // Check if stopped 293 | this.checkStopped(); 294 | } 295 | catch (InterruptedException e) 296 | { 297 | ANRSupervisor.Log("Interruption caught."); 298 | break; 299 | } 300 | } 301 | 302 | // Set stop completed flag 303 | this.mStopCompleted = true; 304 | 305 | ANRSupervisor.Log("supervision stopped"); 306 | } 307 | 308 | private synchronized void checkStopped() throws InterruptedException 309 | { 310 | if (this.mStopped) 311 | { 312 | // Wait 1 second 313 | Thread.sleep(1000); 314 | 315 | // Break if still stopped 316 | if (this.mStopped) 317 | { 318 | throw new InterruptedException(); 319 | } 320 | } 321 | } 322 | 323 | synchronized void stop() 324 | { 325 | ANRSupervisor.Log("Stopping..."); 326 | this.mStopped = true; 327 | } 328 | 329 | synchronized void resume() 330 | { 331 | ANRSupervisor.Log("Resuming..."); 332 | this.mStopped = false; 333 | } 334 | 335 | synchronized boolean isStopped() { return this.mStopCompleted; } 336 | } 337 | 338 | // A {@link Runnable} which calls {@link #notifyAll()} when run. 339 | class ANRSupervisorCallback implements Runnable 340 | { 341 | private boolean mCalled; 342 | 343 | public ANRSupervisorCallback() { super(); } 344 | 345 | @Override public synchronized void run() 346 | { 347 | this.mCalled = true; 348 | this.notifyAll(); 349 | } 350 | 351 | synchronized boolean isCalled() { return this.mCalled; } 352 | } 353 | //*/ 354 | -------------------------------------------------------------------------------- /Assets/PreemptANRs.cs: -------------------------------------------------------------------------------- 1 | #define UNITY_ANDROID // remove this line 2 | 3 | class Game 4 | { 5 | public OnStartup() 6 | { 7 | #if UNITY_ANDROID// && ANR_SUPERVISOR 8 | var ANRSupervisor = new AndroidJavaClass("ANRSupervisor"); 9 | ANRSupervisor.CallStatic("create"); 10 | 11 | // Uncomment if ANRSupervisor should always run, not just during ads. 12 | ANRSupervisor.CallStatic("start"); 13 | #endif // UNITY_ANDROID 14 | } 15 | 16 | // If your reporting is done in C#, you can use this function to grab the report. 17 | void Update() 18 | { 19 | #if UNITY_ANDROID// && ANR_SUPERVISOR 20 | var ANRSupervisor = new AndroidJavaClass("ANRSupervisor"); 21 | var anrReport = ANRSupervisor.CallStatic("getReport"); 22 | if (!anrReport.IsNullOrEmpty()) 23 | { 24 | PlayFabSimpleJson.TryDeserializeObject(anrReport, out var obj); 25 | SGDebug.LogR(LogTag.System, $"Reporting ANR throught PlayFab: {obj}"); 26 | PlayFabManager.Instance.ExeCloudScript("reportANR", new Dictionary { 27 | {"report", obj ?? anrReport}, 28 | }, res => { 29 | ANRSupervisor.CallStatic("reportSent"); 30 | }); 31 | } 32 | #endif // UNITY_ANDROID 33 | } 34 | 35 | public void OnTestButtonClicked() 36 | { 37 | #if UNITY_ANDROID// && ANR_SUPERVISOR 38 | // Make sure the Supervisor is running 39 | var ANRSupervisor = new AndroidJavaClass("ANRSupervisor"); 40 | ANRSupervisor.CallStatic("start"); 41 | 42 | // Generate an ANR on the main Java thread 43 | ANRSupervisor.CallStatic("generateANROnMainThreadTEST"); 44 | 45 | // Test the reporting function 46 | //var anrReport = new ANRReport() { callstacks = new List() { 47 | // "A", 48 | // "B", 49 | //} }; 50 | //PlayFabManager.Instance.ExeCloudScript("reportANR", new Dictionary { 51 | // {"report", anrReport}, 52 | //}); 53 | 54 | // Generate an ANR on the Unity thread (but that would not cause a Google ANR) 55 | //for (var i = 0; i < 60; ++i) // ANR! 56 | //{ 57 | // System.Threading.Thread.Sleep(1000); // ANR! 58 | //} 59 | #endif // UNITY_ANDROID 60 | } 61 | } 62 | 63 | class AdHandler 64 | { 65 | public void ShowAd() 66 | { 67 | #if UNITY_ANDROID// && ANR_SUPERVISOR 68 | // Make sure the ANRSupervisor is running during ad views 69 | var ANRSupervisor = new AndroidJavaClass("ANRSupervisor"); 70 | ANRSupervisor.CallStatic("start"); 71 | #endif // UNITY_ANDROID 72 | } 73 | 74 | private void FinalizeAdSequence() 75 | { 76 | #if UNITY_ANDROID// && ANR_SUPERVISOR 77 | // Uncomment if ANRSupervisor should only run during ads. 78 | var ANRSupervisor = new AndroidJavaClass("ANRSupervisor"); 79 | ANRSupervisor.CallStatic("stop"); 80 | #endif // UNITY_ANDROID 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Assets/StoreReportInCosmosDBUsingPlayFab.cs: -------------------------------------------------------------------------------- 1 | namespace StoryGiant 2 | { 3 | public class CosmosDBTableEntry 4 | { 5 | public string id; 6 | public string PlayFabID; 7 | public string Timestamp; 8 | } 9 | 10 | public class ANRReport : CosmosDBTableEntry 11 | { 12 | public string TitleId; 13 | public object Report; 14 | } 15 | 16 | public static class CloudFunctions 17 | { 18 | const string DATABASE_ID = "xxxxxxxxx"; 19 | const string DATABASE_URL = "DATABASE_URL"; 20 | const string DATABASE_PRIMARY_KEY = "DATABASE_PRIMARY_KEY"; 21 | const string ANR_REPORTS_TABLE = "anr_reports"; 22 | 23 | public static async Task StoreObjectInDB(string containerId, T item) where T : CosmosDBTableEntry 24 | { 25 | var timestamp = $"{DateTime.UtcNow:o}"; 26 | item.id = $"{item.PlayFabID} {timestamp}"; 27 | item.Timestamp = timestamp; 28 | 29 | var endpointUri = Environment.GetEnvironmentVariable(DATABASE_URL, EnvironmentVariableTarget.Process); 30 | var primaryKey = Environment.GetEnvironmentVariable(DATABASE_PRIMARY_KEY, EnvironmentVariableTarget.Process); 31 | var cosmosClient = new CosmosClient(endpointUri, primaryKey); 32 | 33 | Container container; 34 | bool create = true; // For now. Remove when names are settled. 35 | if (create) 36 | { 37 | var database = (await cosmosClient.CreateDatabaseIfNotExistsAsync(DATABASE_ID)).Database; 38 | container = (await database.CreateContainerIfNotExistsAsync(containerId, "/PlayFabID")).Container; 39 | } 40 | else 41 | { 42 | container = cosmosClient.GetDatabase(DATABASE_ID).GetContainer(containerId); 43 | } 44 | await container.UpsertItemAsync(item, new PartitionKey(item.PlayFabID)); 45 | } 46 | 47 | [FunctionName("reportANR")] 48 | public static async Task RunReportANR([HttpTrigger(AuthorizationLevel.Anonymous, GET, POST, Route = null)] HttpRequestMessage req, ILogger log) 49 | { 50 | var context = new Context(req, log); 51 | try 52 | { 53 | await context.Create(); 54 | context.Parameters.TryGetValue("report", out var reportObject); 55 | 56 | await StoreObjectInDB(ANR_REPORTS_TABLE, new ANRReport 57 | { 58 | id = $"{context.CurrentPlayerId} {DateTime.UtcNow:o}", 59 | PlayFabID = context.CurrentPlayerId, 60 | Timestamp = $"{DateTime.UtcNow:o}", 61 | 62 | TitleId = context.TitleInfo.Name, 63 | Report = reportObject, 64 | }); 65 | } 66 | catch (Exception e) 67 | { 68 | return new ExceptionResult(log, e); 69 | } 70 | return new OkObjectResult(OK); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Cobbled together by: Luc Bloom (2022) 2 | 3 | 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: 4 | 5 | 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. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ANR Supervisor 2 | A system that detects ANRs in the main thread and closes the game before the ANR is reported to Google. 3 | It reports the ANR to our own backend, which stores it in a Database for review. 4 | 5 | This alleviates the ANR rate reported by Google Analytics. Most ANRs are caused by Ad SDKs (e.g. Google's own AdMob), so there is an option to only enable it during ad views. 6 | 7 | Another solution could be to make a list of problematic devices and not show ads on those devices, but that's a project for another time. 8 | 9 | Implementation details: 10 | - The code snippets in PreemptANRs.cs need to be incorporated into yuor own code. 11 | - The Java file goes into your project folder on the path that it's already at. 12 | - StoreReportInCosmosDBUsingPlayFab contains some code that can be used in an Azure Functions App. 13 | --------------------------------------------------------------------------------