Repository: beeware/briefcase
Pull request #728 (2022-05-02 21:49:24)

Improve Subprocess Usage and Output Parsing
## Parsing `check_output` Return Values
In certain uses of `check_output`, issues can arise when processing the command output (as opposed to the command itself failing). Because the errors occur after `check_output` returns, the logging in #704 will not help users understand these errors.

These changes implement a new wrapper for `check_output` to invoke a supplied parser function on the command output. If the parsing fails, then the command, command output, and failure reason will be logged.

The calls to `simctl` on macOS were updated to use this new functionality.

### Implementation Overview
- Created `parse_output` as a wrapper for `check_output`
- `parse_output` takes an additional argument `parser`
  - `parser` is a function that takes a string as its first argument and returns the parsed data
  - If `parser` encounters an error, it should raise `ParserError` so `parse_output` can catch it and initiate error logging 
  - Error logging includes the command, its output, and a failure reason from `parser`
  - For parsing errors, `parse_output` will raise `CommandOutputParseError` for the calling code.
- A general parser for JSON was implemented for arbitrary callers.

## Defaulting `text=True` for all Subprocess calls
I also updated the usage of subprocess to include `text=True` by default. I did this for two reasons:
1) All calls to subprocess where the output was explicitly referenced, `universal_newlines` was being explicitly passed as True. (I did find an exception to this in `platforms/macOS/__init__.py`, but using `text=True` avoids the need for it to manually decode the bytes.)
2) I realized that the logging from #704 will disregard line breaks in output if `text` (or `universal_newlines`) is not set to `True`. This is because `str(b"...").splitlines()` doesn't work at all like we want.

If we don't want this new `text` default applied to all `subprocess` calls, I can revert this and add in specific checks in the `subprocess` logging to `decode` any output returned as bytes.

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [x] All new features have been tested
- [x] All new features have been documented
- [x] I have read the **CONTRIBUTING.md** file
- [x] I will abide by the code of conduct


codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (8a92294) into [main](https://codecov.io/gh/beeware/briefcase/commit/45a43e10da2a8eda4307ebc9b2b2502952b20156?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (45a43e1) will **decrease** coverage by `0.64%`.
> The diff coverage is `40.62%`.

> :exclamation: Current head 8a92294 differs from pull request most recent head 4adcfc8. Consider uploading reports for the commit 4adcfc8 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.00% <21.73%> (-18.00%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `93.81% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (4adcfc8) into [main](https://codecov.io/gh/beeware/briefcase/commit/45a43e10da2a8eda4307ebc9b2b2502952b20156?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (45a43e1) will **decrease** coverage by `0.64%`.
> The diff coverage is `40.62%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.00% <21.73%> (-18.00%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `93.81% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (8a92294) into [main](https://codecov.io/gh/beeware/briefcase/commit/45a43e10da2a8eda4307ebc9b2b2502952b20156?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (45a43e1) will **decrease** coverage by `0.64%`.
> The diff coverage is `40.62%`.

> :exclamation: Current head 8a92294 differs from pull request most recent head 4f6a632. Consider uploading reports for the commit 4f6a632 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.00% <21.73%> (-18.00%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `93.81% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (4f6a632) into [main](https://codecov.io/gh/beeware/briefcase/commit/45a43e10da2a8eda4307ebc9b2b2502952b20156?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (45a43e1) will **decrease** coverage by `0.64%`.
> The diff coverage is `44.11%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.35% <28.00%> (-17.65%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `93.81% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (4f6a632) into [main](https://codecov.io/gh/beeware/briefcase/commit/45a43e10da2a8eda4307ebc9b2b2502952b20156?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (45a43e1) will **decrease** coverage by `0.64%`.
> The diff coverage is `44.11%`.

> :exclamation: Current head 4f6a632 differs from pull request most recent head 8a7a975. Consider uploading reports for the commit 8a7a975 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.35% <28.00%> (-17.65%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `93.81% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (4f6a632) into [main](https://codecov.io/gh/beeware/briefcase/commit/45a43e10da2a8eda4307ebc9b2b2502952b20156?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (45a43e1) will **decrease** coverage by `0.64%`.
> The diff coverage is `44.11%`.

> :exclamation: Current head 4f6a632 differs from pull request most recent head 8b0c033. Consider uploading reports for the commit 8b0c033 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.35% <28.00%> (-17.65%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `93.81% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (8b0c033) into [main](https://codecov.io/gh/beeware/briefcase/commit/45a43e10da2a8eda4307ebc9b2b2502952b20156?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (45a43e1) will **decrease** coverage by `0.64%`.
> The diff coverage is `44.11%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.35% <28.00%> (-17.65%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `93.81% <100.00%> (ø)` | |

rmartin16: @freakboy3742, when you get a chance, I would appreciate your feedback on the high level decisions I've made in this PR.
 - Any tweaks to the check_output parsing?
 - Other `check_output` uses that should implement this?
   - After reviewing them, I didn't really see near as much value as with the `simctl` calls.
   - I initially get create a parser function for `AndroidSDK.devices()`.....but it felt a little overdone since it didn't ever seem likely to meaningfully fail there.
 - Yay or nay to `text=true`?
   - One concern I have is if `errors` or `encoding` need appropriate defaults as well.

Additionally, while I don't own any apple products, I was surprisingly able to finally get macOS running in a virtualbox vm. That'll hopefully let me get better familiarity with briefcase on macOS.
codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (8b0c033) into [main](https://codecov.io/gh/beeware/briefcase/commit/45a43e10da2a8eda4307ebc9b2b2502952b20156?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (45a43e1) will **decrease** coverage by `0.64%`.
> The diff coverage is `44.11%`.

> :exclamation: Current head 8b0c033 differs from pull request most recent head 839baa9. Consider uploading reports for the commit 839baa9 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.35% <28.00%> (-17.65%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `93.81% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (839baa9) into [main](https://codecov.io/gh/beeware/briefcase/commit/45a43e10da2a8eda4307ebc9b2b2502952b20156?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (45a43e1) will **decrease** coverage by `0.64%`.
> The diff coverage is `44.11%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.35% <28.00%> (-17.65%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `93.81% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (839baa9) into [main](https://codecov.io/gh/beeware/briefcase/commit/b5726642205d1eca7bb7ba01150f1e515d087c56?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (b572664) will **decrease** coverage by `0.64%`.
> The diff coverage is `44.11%`.

> :exclamation: Current head 839baa9 differs from pull request most recent head 3c4bc94. Consider uploading reports for the commit 3c4bc94 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.35% <28.00%> (-17.65%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `93.81% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (3c4bc94) into [main](https://codecov.io/gh/beeware/briefcase/commit/b5726642205d1eca7bb7ba01150f1e515d087c56?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (b572664) will **decrease** coverage by `0.70%`.
> The diff coverage is `46.15%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.35% <28.00%> (-17.65%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `92.07% <81.81%> (-1.74%)` | :arrow_down: |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (3c4bc94) into [main](https://codecov.io/gh/beeware/briefcase/commit/b5726642205d1eca7bb7ba01150f1e515d087c56?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (b572664) will **decrease** coverage by `0.70%`.
> The diff coverage is `46.15%`.

> :exclamation: Current head 3c4bc94 differs from pull request most recent head 97d40a9. Consider uploading reports for the commit 97d40a9 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.35% <28.00%> (-17.65%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `92.07% <81.81%> (-1.74%)` | :arrow_down: |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (97d40a9) into [main](https://codecov.io/gh/beeware/briefcase/commit/b5726642205d1eca7bb7ba01150f1e515d087c56?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (b572664) will **decrease** coverage by `0.70%`.
> The diff coverage is `51.16%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.69% <37.93%> (-17.31%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `92.07% <81.81%> (-1.74%)` | :arrow_down: |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (97d40a9) into [main](https://codecov.io/gh/beeware/briefcase/commit/b5726642205d1eca7bb7ba01150f1e515d087c56?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (b572664) will **decrease** coverage by `0.70%`.
> The diff coverage is `51.16%`.

> :exclamation: Current head 97d40a9 differs from pull request most recent head c6cf5d1. Consider uploading reports for the commit c6cf5d1 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `82.69% <37.93%> (-17.31%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `91.13% <66.66%> (-0.97%)` | :arrow_down: |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `92.07% <81.81%> (-1.74%)` | :arrow_down: |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (c6cf5d1) into [main](https://codecov.io/gh/beeware/briefcase/commit/b5726642205d1eca7bb7ba01150f1e515d087c56?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (b572664) will **decrease** coverage by `0.01%`.
> The diff coverage is `95.34%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `92.07% <81.81%> (-1.74%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `92.40% <100.00%> (+0.29%)` | :arrow_up: |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (c6cf5d1) into [main](https://codecov.io/gh/beeware/briefcase/commit/b5726642205d1eca7bb7ba01150f1e515d087c56?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (b572664) will **decrease** coverage by `0.01%`.
> The diff coverage is `95.34%`.

> :exclamation: Current head c6cf5d1 differs from pull request most recent head 10e3c27. Consider uploading reports for the commit 10e3c27 to get more accurate results

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `92.07% <81.81%> (-1.74%)` | :arrow_down: |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `92.40% <100.00%> (+0.29%)` | :arrow_up: |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |

codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#728](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (10e3c27) into [main](https://codecov.io/gh/beeware/briefcase/commit/b5726642205d1eca7bb7ba01150f1e515d087c56?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (b572664) will **increase** coverage by `0.05%`.
> The diff coverage is `100.00%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/integrations/android\_sdk.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvYW5kcm9pZF9zZGsucHk=) | `98.85% <ø> (ø)` | |
| [src/briefcase/integrations/docker.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvZG9ja2VyLnB5) | `91.25% <ø> (ø)` | |
| [src/briefcase/integrations/java.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvamF2YS5weQ==) | `100.00% <ø> (ø)` | |
| [src/briefcase/exceptions.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9leGNlcHRpb25zLnB5) | `92.40% <100.00%> (+0.29%)` | :arrow_up: |
| [src/briefcase/integrations/subprocess.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMvc3VicHJvY2Vzcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/briefcase/integrations/xcode.py](https://codecov.io/gh/beeware/briefcase/pull/728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9pbnRlZ3JhdGlvbnMveGNvZGUucHk=) | `94.05% <100.00%> (+0.24%)` | :arrow_up: |


Patch:
diff --git a/changes/728.feature.rst b/changes/728.feature.rst
new file mode 100644
index 00000000..40fa07be
--- /dev/null
+++ b/changes/728.feature.rst
@@ -0,0 +1,1 @@
+When the output of a wrapped command cannot be parsed, full command output, and failure reason is now logged.
diff --git a/src/briefcase/exceptions.py b/src/briefcase/exceptions.py
index 2e0efdcb..49b4c13b 100644
--- a/src/briefcase/exceptions.py
+++ b/src/briefcase/exceptions.py
@@ -113,3 +113,8 @@ class CorruptToolError(BriefcaseCommandError):
     def __init__(self, tool):
         self.tool = tool
         super().__init__(msg=f"{tool!r} found, but it appears to be corrupted.")
+
+
+class CommandOutputParseError(BriefcaseCommandError):
+    def __init__(self, parse_error):
+        super().__init__(msg=f"Unable to parse command output: {parse_error}")
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index 7006368f..58b48dba 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -288,8 +288,6 @@ connection.
 
         self.command.logger.info("Downloading the Android emulator and system image...")
         try:
-            # Using `check_output` and `stderr=STDOUT` so we buffer output,
-            # displaying it only if an exception occurs.
             self.command.subprocess.run(
                 [
                     os.fsdecode(self.sdkmanager_path),
@@ -315,7 +313,6 @@ connection.
             # stderr data is in `e.output`.
             output = self.command.subprocess.check_output(
                 [os.fsdecode(self.emulator_path), "-list-avds"],
-                universal_newlines=True,
                 stderr=subprocess.STDOUT,
             ).strip()
 
@@ -335,7 +332,6 @@ connection.
             # stderr data is in `e.output`.
             output = self.command.subprocess.check_output(
                 [os.fsdecode(self.adb_path), "devices", "-l"],
-                universal_newlines=True,
                 stderr=subprocess.STDOUT,
             ).strip()
 
@@ -606,7 +602,6 @@ An emulator named '{avd}' already exists.
                     "--device", device_type,
                 ],
                 env=self.env,
-                universal_newlines=True,
                 stderr=subprocess.STDOUT,
             )
         except subprocess.CalledProcessError:
@@ -816,7 +811,7 @@ class ADB:
 
         :param arguments: List of strings to pass to `adb` as arguments.
 
-        Returns bytes of `adb` output on success; raises an exception on failure.
+        Returns `adb` output on success; raises an exception on failure.
         """
         # The ADB integration operates on the basis of running commands before
         # checking that they are valid, then parsing output to notice errors.
@@ -831,7 +826,6 @@ class ADB:
                     self.device,
                 ]
                 + [(os.fsdecode(arg) if isinstance(arg, Path) else arg) for arg in arguments],
-                universal_newlines=True,
                 stderr=subprocess.STDOUT,
             )
         except subprocess.CalledProcessError as e:
diff --git a/src/briefcase/integrations/docker.py b/src/briefcase/integrations/docker.py
index e50e415e..b175d95b 100644
--- a/src/briefcase/integrations/docker.py
+++ b/src/briefcase/integrations/docker.py
@@ -105,7 +105,6 @@ need to restart your terminal session.
         # Try to get the version of docker that is installed.
         output = command.subprocess.check_output(
             ['docker', '--version'],
-            universal_newlines=True,
             stderr=subprocess.STDOUT,
         ).strip('\n')
 
@@ -182,9 +181,8 @@ installation, and try again.
         # We don't care about the output, just that it succeeds.
         command.subprocess.check_output(
             ['docker', 'info'],
-            universal_newlines=True,
             stderr=subprocess.STDOUT,
-        ).strip('\n')
+        )
     except subprocess.CalledProcessError as e:
         failure_output = e.output
         if 'permission denied while trying to connect' in failure_output:
diff --git a/src/briefcase/integrations/java.py b/src/briefcase/integrations/java.py
index 208939d0..803d43be 100644
--- a/src/briefcase/integrations/java.py
+++ b/src/briefcase/integrations/java.py
@@ -75,7 +75,6 @@ class JDK:
                 # raises an error.
                 java_home = command.subprocess.check_output(
                     ['/usr/libexec/java_home'],
-                    universal_newlines=True,
                     stderr=subprocess.STDOUT,
                 ).strip('\n')
             except subprocess.CalledProcessError:
@@ -91,7 +90,6 @@ class JDK:
                         os.fsdecode(Path(java_home) / 'bin' / 'javac'),
                         '-version',
                     ],
-                    universal_newlines=True,
                     stderr=subprocess.STDOUT,
                 )
                 # This should be a string of the form "javac 1.8.0_144\n"
diff --git a/src/briefcase/integrations/subprocess.py b/src/briefcase/integrations/subprocess.py
index bbf8109d..290dd4f7 100644
--- a/src/briefcase/integrations/subprocess.py
+++ b/src/briefcase/integrations/subprocess.py
@@ -1,6 +1,30 @@
+import json
 import shlex
 import subprocess
 
+from briefcase.exceptions import CommandOutputParseError
+
+
+class ParseError(Exception):
+    """Raised by parser functions to signal parsing was unsuccessful"""
+
+
+def ensure_str(text):
+    """Returns input text as a string."""
+    return text.decode() if isinstance(text, bytes) else str(text)
+
+
+def json_parser(json_output):
+    """
+    Wrapper to parse command output as JSON via parse_output.
+
+    :param json_output: command output to parse as JSON
+    """
+    try:
+        return json.loads(json_output)
+    except json.JSONDecodeError as e:
+        raise ParseError(f"Failed to parse output as JSON: {e}")
+
 
 class Subprocess:
     """
@@ -37,6 +61,7 @@ class Subprocess:
         This involves:
          * Converting any environment overrides into a full environment
          * Converting the `cwd` into a string
+         * Default `text` to True so all outputs are strings
         """
         # If `env` has been provided, inject a full copy of the local
         # environment, with the values in `env` overriding the local
@@ -55,6 +80,12 @@ class Subprocess:
         except KeyError:
             pass
 
+        # if `text` or backwards-compatible `universal_newlines` are
+        # not provided, then default `text` to True so all output is
+        # returned as strings instead of bytes.
+        if not ('text' in kwargs or 'universal_newlines' in kwargs):
+            kwargs['text'] = True
+
         return kwargs
 
     def run(self, args, **kwargs):
@@ -62,9 +93,12 @@ class Subprocess:
         A wrapper for subprocess.run()
 
         The behavior of this method is identical to subprocess.run(),
-        except for the `env` argument. If provided, the current system
-        environment will be copied, and the contents of env overwritten
-        into that environment.
+        except for:
+         - If the `env` argument is provided, the current system environment
+           will be copied, and the contents of env overwritten into that
+           environment.
+         - The `text` argument is defaulted to True so all output
+           is returned as strings instead of bytes.
         """
         # Invoke subprocess.run().
         # Pass through all arguments as-is.
@@ -90,10 +124,13 @@ class Subprocess:
         """
         A wrapper for subprocess.check_output()
 
-        The behavior of this method is identical to subprocess.check_output(),
-        except for the `env` argument. If provided, the current system
-        environment will be copied, and the contents of env overwritten
-        into that environment.
+        The behavior of this method is identical to
+        subprocess.check_output(), except for:
+         - If the `env` is argument provided, the current system environment
+           will be copied, and the contents of env overwritten into that
+           environment.
+         - The `text` argument is defaulted to True so all output
+           is returned as strings instead of bytes.
         """
         self._log_command(args)
         self._log_environment(kwargs.get("env"))
@@ -114,14 +151,50 @@ class Subprocess:
         self._log_return_code(0)
         return cmd_output
 
+    def parse_output(self, output_parser, args, **kwargs):
+        """
+        A wrapper for check_output() where the command output is processed
+        through the supplied parser function.
+
+        If the parser fails, CommandOutputParseError is raised.
+        The parsing function should take one string argument and should
+        raise ParseError for failure modes.
+
+        :param output_parser: a function that takes str input and returns
+            parsed content, or raises ParseError in the case of a parsing
+            problem.
+        :param args: The arguments to pass to the subprocess
+        :param kwargs: The keyword arguments to pass to the subprocess
+        :returns: Parsed data read from the subprocess output; the exact
+            structure of that data is dependent on the output parser used.
+        """
+        cmd_output = self.check_output(args, **kwargs)
+
+        try:
+            return output_parser(cmd_output)
+        except ParseError as e:
+            error_reason = str(e) or f"Failed to parse command output using '{output_parser.__name__}'"
+            self.command.logger.error()
+            self.command.logger.error("Command Output Parsing Error:")
+            self.command.logger.error(f"    {error_reason}")
+            self.command.logger.error("Command:")
+            self.command.logger.error(f"    {' '.join(shlex.quote(str(arg)) for arg in args)}")
+            self.command.logger.error("Command Output:")
+            for line in ensure_str(cmd_output).splitlines():
+                self.command.logger.error(f"    {line}")
+            raise CommandOutputParseError(error_reason)
+
     def Popen(self, args, **kwargs):
         """
         A wrapper for subprocess.Popen()
 
-        The behavior of this method is identical to subprocess.Popen(),
-        except for the `env` argument. If provided, the current system
-        environment will be copied, and the contents of env overwritten
-        into that environment.
+        The behavior of this method is identical to
+        subprocess.check_output(), except for:
+         - If the `env` argument is provided, the current system environment
+           will be copied, and the contents of env overwritten into that
+           environment.
+         - The `text` argument is defaulted to True so all output
+           is returned as strings instead of bytes.
         """
         self._log_command(args)
         self._log_environment(kwargs.get("env"))
@@ -169,12 +242,12 @@ class Subprocess:
         """
         if output:
             self.command.logger.deep_debug("Command Output:")
-            for line in str(output).splitlines():
+            for line in ensure_str(output).splitlines():
                 self.command.logger.deep_debug(f"    {line}")
 
         if stderr:
             self.command.logger.deep_debug("Command Error Output (stderr):")
-            for line in str(stderr).splitlines():
+            for line in ensure_str(stderr).splitlines():
                 self.command.logger.deep_debug(f"    {line}")
 
     def _log_return_code(self, return_code):
diff --git a/src/briefcase/integrations/xcode.py b/src/briefcase/integrations/xcode.py
index 7575c7a0..1b4eae8d 100644
--- a/src/briefcase/integrations/xcode.py
+++ b/src/briefcase/integrations/xcode.py
@@ -1,10 +1,10 @@
 import enum
-import json
 import re
 import subprocess
 from pathlib import Path
 
-from briefcase.exceptions import BriefcaseCommandError
+from briefcase.integrations.subprocess import json_parser
+from briefcase.exceptions import BriefcaseCommandError, CommandOutputParseError
 
 
 class DeviceState(enum.Enum):
@@ -140,7 +140,6 @@ def ensure_xcode_is_installed(
             output = command.subprocess.check_output(
                 ['xcode-select', '-p'],
                 stderr=subprocess.STDOUT,
-                universal_newlines=True
             )
             xcode_location = output.strip()
         except subprocess.CalledProcessError:
@@ -167,7 +166,6 @@ Re-run Briefcase once that installation is complete.
         output = command.subprocess.check_output(
             ['xcodebuild', '-version'],
             stderr=subprocess.STDOUT,
-            universal_newlines=True
         )
 
         if min_version is not None:
@@ -375,11 +373,9 @@ and install the simulator.
 Press Return to continue: """)
 
     try:
-        simctl_data = json.loads(
-            command.subprocess.check_output(
-                ['xcrun', 'simctl', 'list', '-j'],
-                universal_newlines=True
-            )
+        simctl_data = command.subprocess.parse_output(
+            json_parser,
+            ['xcrun', 'simctl', 'list', '-j'],
         )
 
         os_versions = {
@@ -419,10 +415,10 @@ Press Return to continue: """)
 
         return simulators
 
+    except CommandOutputParseError:
+        raise BriefcaseCommandError("Unable to parse output of xcrun simctl")
     except subprocess.CalledProcessError:
-        raise BriefcaseCommandError(
-            "Unable to run xcrun simctl."
-        )
+        raise BriefcaseCommandError("Unable to run xcrun simctl.")
 
 
 def get_device_state(command, udid):
@@ -434,11 +430,9 @@ def get_device_state(command, udid):
     :returns: The status of the device, as a DeviceState enum.
     """
     try:
-        simctl_data = json.loads(
-            command.subprocess.check_output(
-                ['xcrun', 'simctl', 'list', 'devices', '-j', udid],
-                universal_newlines=True
-            )
+        simctl_data = command.subprocess.parse_output(
+            json_parser,
+            ['xcrun', 'simctl', 'list', 'devices', '-j', udid],
         )
 
         for runtime, devices in simctl_data['devices'].items():
@@ -453,6 +447,8 @@ def get_device_state(command, udid):
         # If we fall out the bottom of the loop, the UDID didn't match
         # so we raise an error.
         raise BriefcaseCommandError(f"Unable to determine status of device {udid}.")
+    except CommandOutputParseError:
+        raise BriefcaseCommandError("Unable to parse output of xcrun simctl")
     except subprocess.CalledProcessError:
         raise BriefcaseCommandError("Unable to run xcrun simctl.")
 
@@ -471,7 +467,6 @@ def get_identities(command, policy):
     try:
         output = command.subprocess.check_output(
             ['security', 'find-identity', '-v', '-p', policy],
-            universal_newlines=True
         )
 
         identities = dict(
@@ -482,6 +477,4 @@ def get_identities(command, policy):
 
         return identities
     except subprocess.CalledProcessError:
-        raise BriefcaseCommandError(
-            "Unable to run xcrun simctl."
-        )
+        raise BriefcaseCommandError("Unable to run security find-identity.")
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 2cf03b59..2d269899 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -183,7 +183,7 @@ class macOSSigningMixin:
                 check=True,
             )
         except subprocess.CalledProcessError as e:
-            errors = e.stderr.decode('utf-8', errors='replace')
+            errors = e.stderr
             if 'code object is not signed at all' in errors:
                 self.logger.info("... file requires a deep sign; retrying")
                 try:


Test patch:
diff --git a/tests/integrations/android_sdk/ADB/test_command.py b/tests/integrations/android_sdk/ADB/test_command.py
index 536c6e23..58f66456 100644
--- a/tests/integrations/android_sdk/ADB/test_command.py
+++ b/tests/integrations/android_sdk/ADB/test_command.py
@@ -24,7 +24,6 @@ def test_simple_command(mock_sdk, tmp_path):
             "example",
             "command",
         ],
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     )
 
@@ -66,6 +65,5 @@ def test_error_handling(mock_sdk, tmp_path, name, exception):
             "example",
             "command",
         ],
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     )
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py
index 6e73bcc9..a52a1014 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py
@@ -74,7 +74,6 @@ def test_create_emulator(mock_sdk, tmp_path):
             "--device", "pixel",
         ],
         env=mock_sdk.env,
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     )
 
@@ -138,7 +137,6 @@ def test_create_preexisting_skins(mock_sdk, tmp_path):
             "--device", "pixel",
         ],
         env=mock_sdk.env,
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     )
 
@@ -178,7 +176,6 @@ def test_create_failure(mock_sdk):
             "--device", "pixel",
         ],
         env=mock_sdk.env,
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     )
 
@@ -207,7 +204,6 @@ def test_download_failure(mock_sdk, tmp_path):
             "--device", "pixel",
         ],
         env=mock_sdk.env,
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     )
 
@@ -257,7 +253,6 @@ def test_unpack_failure(mock_sdk, tmp_path):
             "--device", "pixel",
         ],
         env=mock_sdk.env,
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     )
 
diff --git a/tests/integrations/docker/test_Docker__prepare.py b/tests/integrations/docker/test_Docker__prepare.py
index 919bb614..f1c54a90 100644
--- a/tests/integrations/docker/test_Docker__prepare.py
+++ b/tests/integrations/docker/test_Docker__prepare.py
@@ -24,6 +24,7 @@ def test_prepare(mock_docker, tmp_path):
             os.fsdecode(tmp_path / "base" / "path" / "to" / "src")
         ],
         check=True,
+        text=True,
     )
 
 
@@ -50,4 +51,5 @@ def test_prepare_failure(mock_docker, tmp_path):
             os.fsdecode(tmp_path / "base" / "path" / "to" / "src")
         ],
         check=True,
+        text=True,
     )
diff --git a/tests/integrations/docker/test_Docker__run.py b/tests/integrations/docker/test_Docker__run.py
index 4b42ca58..4615e6df 100644
--- a/tests/integrations/docker/test_Docker__run.py
+++ b/tests/integrations/docker/test_Docker__run.py
@@ -20,7 +20,8 @@ def test_simple_call(mock_docker, tmp_path, capsys):
             "briefcase/com.example.myapp:py3.X",
             "hello",
             "world"
-        ]
+        ],
+        text=True,
     )
     assert capsys.readouterr().out == ""
 
@@ -60,7 +61,8 @@ def test_simple_call_with_path_arg(mock_docker, tmp_path, capsys):
             "hello",
             os.fsdecode(tmp_path / "location")
         ],
-        cwd=os.fsdecode(tmp_path / "cwd")
+        cwd=os.fsdecode(tmp_path / "cwd"),
+        text=True,
     )
     assert capsys.readouterr().out == ""
 
@@ -82,7 +84,8 @@ def test_simple_call_with_sys_executable_arg(mock_docker, tmp_path, capsys, monk
             "briefcase/com.example.myapp:py3.X",
             "hello",
             "python3.X",
-        ]
+        ],
+        text=True
     )
 
     assert capsys.readouterr().out == ""
@@ -107,7 +110,8 @@ def test_simple_verbose_call(mock_docker, tmp_path, capsys):
             "briefcase/com.example.myapp:py3.X",
             "hello",
             "world",
-        ]
+        ],
+        text=True,
     )
     assert capsys.readouterr().out == (
         "\n"
diff --git a/tests/integrations/docker/test_verify_docker.py b/tests/integrations/docker/test_verify_docker.py
index b2ba78cd..b64606c9 100644
--- a/tests/integrations/docker/test_verify_docker.py
+++ b/tests/integrations/docker/test_verify_docker.py
@@ -37,10 +37,10 @@ def test_docker_exists(test_command, capsys):
     ) = test_command.subprocess.check_output.call_args_list
 
     assert docker_version_called_with[0] == (['docker', '--version'],)
-    assert docker_version_called_with[1] == {'universal_newlines': True, 'stderr': subprocess.STDOUT}
+    assert docker_version_called_with[1] == {'stderr': subprocess.STDOUT}
 
     assert docker_info_called_with[0] == (['docker', 'info'],)
-    assert docker_info_called_with[1] == {'universal_newlines': True, 'stderr': subprocess.STDOUT}
+    assert docker_info_called_with[1] == {'stderr': subprocess.STDOUT}
 
     # No console output
     output = capsys.readouterr()
@@ -60,7 +60,6 @@ def test_docker_doesnt_exist(test_command):
     # But docker was invoked
     test_command.subprocess.check_output.assert_called_with(
         ['docker', '--version'],
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     )
 
@@ -87,10 +86,10 @@ def test_docker_failure(test_command, capsys):
     ) = test_command.subprocess.check_output.call_args_list
 
     assert docker_version_called_with[0] == (['docker', '--version'],)
-    assert docker_version_called_with[1] == {'universal_newlines': True, 'stderr': subprocess.STDOUT}
+    assert docker_version_called_with[1] == {'stderr': subprocess.STDOUT}
 
     assert docker_info_called_with[0] == (['docker', 'info'],)
-    assert docker_info_called_with[1] == {'universal_newlines': True, 'stderr': subprocess.STDOUT}
+    assert docker_info_called_with[1] == {'stderr': subprocess.STDOUT}
 
     # console output
     output = capsys.readouterr()
@@ -125,10 +124,10 @@ def test_docker_unknown_version(test_command, capsys):
     docker_version_called_with, docker_info_called_with = test_command.subprocess.check_output.call_args_list
 
     assert docker_version_called_with[0] == (['docker', '--version'],)
-    assert docker_version_called_with[1] == {'universal_newlines': True, 'stderr': subprocess.STDOUT}
+    assert docker_version_called_with[1] == {'stderr': subprocess.STDOUT}
 
     assert docker_info_called_with[0] == (['docker', 'info'],)
-    assert docker_info_called_with[1] == {'universal_newlines': True, 'stderr': subprocess.STDOUT}
+    assert docker_info_called_with[1] == {'stderr': subprocess.STDOUT}
 
     # console output
     output = capsys.readouterr()
diff --git a/tests/integrations/java/test_JDK__verify.py b/tests/integrations/java/test_JDK__verify.py
index abcf16af..284da474 100644
--- a/tests/integrations/java/test_JDK__verify.py
+++ b/tests/integrations/java/test_JDK__verify.py
@@ -51,13 +51,11 @@ def test_macos_tool_java_home(test_command, capsys):
         # First call is to /usr/lib/java_home
         mock.call(
             ['/usr/libexec/java_home'],
-            universal_newlines=True,
             stderr=subprocess.STDOUT,
         ),
         # Second is a call to verify a valid Java version
         mock.call(
             [os.fsdecode(Path('/path/to/java/bin/javac')), '-version'],
-            universal_newlines=True,
             stderr=subprocess.STDOUT,
         ),
     ])
@@ -91,7 +89,6 @@ def test_macos_tool_failure(test_command, tmp_path, capsys):
         # First call is to /usr/lib/java_home
         mock.call(
             ['/usr/libexec/java_home'],
-            universal_newlines=True,
             stderr=subprocess.STDOUT,
         ),
     ])
@@ -122,7 +119,6 @@ def test_macos_provided_overrides_tool_java_home(test_command, capsys):
     # A single call to check output
     test_command.subprocess.check_output.assert_called_once_with(
         [os.fsdecode(Path('/path/to/java/bin/javac')), '-version'],
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     ),
 
@@ -149,7 +145,6 @@ def test_valid_provided_java_home(test_command, capsys):
     # A single call to check output
     test_command.subprocess.check_output.assert_called_once_with(
         [os.fsdecode(Path('/path/to/java/bin/javac')), '-version'],
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     ),
 
@@ -179,7 +174,6 @@ def test_invalid_jdk_version(test_command, tmp_path, capsys):
     # A single call was made to check javac
     test_command.subprocess.check_output.assert_called_once_with(
         [os.fsdecode(Path('/path/to/java/bin/javac')), '-version'],
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     ),
 
@@ -209,7 +203,6 @@ def test_no_javac(test_command, tmp_path, capsys):
     # A single call was made to check javac
     test_command.subprocess.check_output.assert_called_once_with(
         [os.fsdecode(Path('/path/to/nowhere/bin/javac')), '-version'],
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     ),
 
@@ -241,7 +234,6 @@ def test_javac_error(test_command, tmp_path, capsys):
     # A single call was made to check javac
     test_command.subprocess.check_output.assert_called_once_with(
         [os.fsdecode(Path('/path/to/java/bin/javac')), '-version'],
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     ),
 
@@ -271,7 +263,6 @@ def test_unparseable_javac_version(test_command, tmp_path, capsys):
     # A single call was made to check javac
     test_command.subprocess.check_output.assert_called_once_with(
         [os.fsdecode(Path('/path/to/java/bin/javac')), '-version'],
-        universal_newlines=True,
         stderr=subprocess.STDOUT,
     ),
 
diff --git a/tests/integrations/subprocess/test_Subprocess__Popen.py b/tests/integrations/subprocess/test_Subprocess__Popen.py
index fd75a308..e621764e 100644
--- a/tests/integrations/subprocess/test_Subprocess__Popen.py
+++ b/tests/integrations/subprocess/test_Subprocess__Popen.py
@@ -1,5 +1,7 @@
 import os
 
+import pytest
+
 from briefcase.console import Log
 
 
@@ -8,7 +10,7 @@ def test_call(mock_sub, capsys):
 
     mock_sub.Popen(['hello', 'world'])
 
-    mock_sub._subprocess.Popen.assert_called_with(['hello', 'world'])
+    mock_sub._subprocess.Popen.assert_called_with(['hello', 'world'], text=True)
     assert capsys.readouterr().out == ""
 
 
@@ -31,7 +33,8 @@ def test_call_with_path_arg(mock_sub, capsys, tmp_path):
 
     mock_sub._subprocess.Popen.assert_called_with(
         ['hello', os.fsdecode(tmp_path / 'location')],
-        cwd=os.fsdecode(tmp_path / 'cwd')
+        cwd=os.fsdecode(tmp_path / 'cwd'),
+        text=True,
     )
     assert capsys.readouterr().out == ""
 
@@ -42,7 +45,7 @@ def test_debug_call(mock_sub, capsys):
 
     mock_sub.Popen(['hello', 'world'])
 
-    mock_sub._subprocess.Popen.assert_called_with(['hello', 'world'])
+    mock_sub._subprocess.Popen.assert_called_with(['hello', 'world'], text=True)
     assert capsys.readouterr().out == (
         "\n"
         ">>> Running Command:\n"
@@ -60,7 +63,7 @@ def test_debug_call_with_env(mock_sub, capsys):
     merged_env = mock_sub.command.os.environ.copy()
     merged_env.update(env)
 
-    mock_sub._subprocess.Popen.assert_called_with(['hello', 'world'], env=merged_env)
+    mock_sub._subprocess.Popen.assert_called_with(['hello', 'world'], env=merged_env, text=True)
 
     expected_output = (
         "\n"
@@ -79,7 +82,7 @@ def test_deep_debug_call(mock_sub, capsys):
 
     mock_sub.Popen(["hello", "world"])
 
-    mock_sub._subprocess.Popen.assert_called_with(["hello", "world"])
+    mock_sub._subprocess.Popen.assert_called_with(["hello", "world"], text=True)
 
     expected_output = (
         "\n"
@@ -107,7 +110,7 @@ def test_deep_debug_call_with_env(mock_sub, capsys):
     merged_env = mock_sub.command.os.environ.copy()
     merged_env.update(env)
 
-    mock_sub._subprocess.Popen.assert_called_with(['hello', 'world'], env=merged_env)
+    mock_sub._subprocess.Popen.assert_called_with(['hello', 'world'], env=merged_env, text=True)
 
     expected_output = (
         "\n"
@@ -124,3 +127,20 @@ def test_deep_debug_call_with_env(mock_sub, capsys):
     )
 
     assert capsys.readouterr().out == expected_output
+
+
+@pytest.mark.parametrize(
+    "in_kwargs, kwargs",
+    [
+        ({}, {'text': True}),
+        ({'text': True}, {'text': True}),
+        ({'text': False}, {'text': False}),
+        ({'universal_newlines': False}, {'universal_newlines': False}),
+        ({'universal_newlines': True}, {'universal_newlines': True}),
+    ]
+)
+def test_text_eq_true_default_overriding(mock_sub, in_kwargs, kwargs):
+    "if text or universal_newlines is explicitly provided, those should override text=true default"
+
+    mock_sub.Popen(['hello', 'world'], **in_kwargs)
+    mock_sub._subprocess.Popen.assert_called_with(["hello", "world"], **kwargs)
diff --git a/tests/integrations/subprocess/test_Subprocess__check_output.py b/tests/integrations/subprocess/test_Subprocess__check_output.py
index fc1c84ea..5b8c0c27 100644
--- a/tests/integrations/subprocess/test_Subprocess__check_output.py
+++ b/tests/integrations/subprocess/test_Subprocess__check_output.py
@@ -11,7 +11,7 @@ def test_call(mock_sub, capsys):
 
     mock_sub.check_output(['hello', 'world'])
 
-    mock_sub._subprocess.check_output.assert_called_with(['hello', 'world'])
+    mock_sub._subprocess.check_output.assert_called_with(['hello', 'world'], text=True)
     assert capsys.readouterr().out == ""
 
 
@@ -22,7 +22,7 @@ def test_call_with_arg(mock_sub, capsys):
 
     mock_sub._subprocess.check_output.assert_called_with(
         ['hello', 'world'],
-        universal_newlines=True
+        universal_newlines=True,
     )
     assert capsys.readouterr().out == ""
 
@@ -34,7 +34,8 @@ def test_call_with_path_arg(mock_sub, capsys, tmp_path):
 
     mock_sub._subprocess.check_output.assert_called_with(
         ['hello', os.fsdecode(tmp_path / 'location')],
-        cwd=os.fsdecode(tmp_path / 'cwd')
+        cwd=os.fsdecode(tmp_path / 'cwd'),
+        text=True,
     )
     assert capsys.readouterr().out == ""
 
@@ -45,7 +46,7 @@ def test_debug_call(mock_sub, capsys):
 
     mock_sub.check_output(['hello', 'world'])
 
-    mock_sub._subprocess.check_output.assert_called_with(['hello', 'world'])
+    mock_sub._subprocess.check_output.assert_called_with(['hello', 'world'], text=True)
 
     assert capsys.readouterr().out == "\n>>> Running Command:\n>>>     hello world\n"
 
@@ -60,7 +61,7 @@ def test_debug_call_with_env(mock_sub, capsys):
     merged_env = mock_sub.command.os.environ.copy()
     merged_env.update(env)
 
-    mock_sub._subprocess.check_output.assert_called_with(['hello', 'world'], env=merged_env)
+    mock_sub._subprocess.check_output.assert_called_with(['hello', 'world'], env=merged_env, text=True)
 
     expected_output = (
         "\n"
@@ -79,7 +80,7 @@ def test_deep_debug_call(mock_sub, capsys):
 
     mock_sub.check_output(["hello", "world"])
 
-    mock_sub._subprocess.check_output.assert_called_with(["hello", "world"])
+    mock_sub._subprocess.check_output.assert_called_with(["hello", "world"], text=True)
 
     expected_output = (
         "\n"
@@ -111,7 +112,7 @@ def test_deep_debug_call_with_env(mock_sub, capsys):
     merged_env = mock_sub.command.os.environ.copy()
     merged_env.update(env)
 
-    mock_sub._subprocess.check_output.assert_called_with(["hello", "world"], env=merged_env)
+    mock_sub._subprocess.check_output.assert_called_with(["hello", "world"], env=merged_env, text=True)
 
     expected_output = (
         "\n"
@@ -170,3 +171,20 @@ def test_calledprocesserror_exception_logging(mock_sub, capsys):
     )
 
     assert capsys.readouterr().out == expected_output
+
+
+@pytest.mark.parametrize(
+    "in_kwargs, kwargs",
+    [
+        ({}, {'text': True}),
+        ({'text': True}, {'text': True}),
+        ({'text': False}, {'text': False}),
+        ({'universal_newlines': False}, {'universal_newlines': False}),
+        ({'universal_newlines': True}, {'universal_newlines': True}),
+    ]
+)
+def test_text_eq_true_default_overriding(mock_sub, in_kwargs, kwargs):
+    "if text or universal_newlines is explicitly provided, those should override text=true default"
+
+    mock_sub.check_output(['hello', 'world'], **in_kwargs)
+    mock_sub._subprocess.check_output.assert_called_with(["hello", "world"], **kwargs)
diff --git a/tests/integrations/subprocess/test_Subprocess__parse_output.py b/tests/integrations/subprocess/test_Subprocess__parse_output.py
new file mode 100644
index 00000000..8268efc8
--- /dev/null
+++ b/tests/integrations/subprocess/test_Subprocess__parse_output.py
@@ -0,0 +1,99 @@
+import pytest
+
+from briefcase.integrations.subprocess import CommandOutputParseError, ParseError
+
+
+def splitlines_parser(data):
+    "A test parser that returns the input data, split by line"
+    return data.splitlines()
+
+
+def second_line_parser(data):
+    "A test parser that returns the second line of input."
+    try:
+        return data.splitlines()[1]
+    except IndexError:
+        raise ParseError("Input does not contain 2 lines")
+
+
+def third_line_parser(data):
+    "A test parser that returns the third line of input."
+    try:
+        return data.splitlines()[2]
+    except IndexError:
+        raise ParseError("Input does not contain 3 lines")
+
+
+def test_call(mock_sub, capsys):
+    "A simple call to check_output will be invoked"
+
+    output = mock_sub.parse_output(splitlines_parser, ['hello', 'world'])
+
+    mock_sub._subprocess.check_output.assert_called_with(['hello', 'world'], text=True)
+    assert capsys.readouterr().out == ""
+
+    assert output == ["some output line 1", "more output line 2"]
+
+
+def test_call_with_arg(mock_sub, capsys):
+    "Any extra keyword arguments are passed through as-is to check_output"
+
+    output = mock_sub.parse_output(splitlines_parser, ['hello', 'world'], extra_arg="asdf")
+
+    mock_sub._subprocess.check_output.assert_called_with(
+        ['hello', 'world'],
+        extra_arg="asdf",
+        text=True,
+    )
+    assert capsys.readouterr().out == ""
+
+    assert output == ["some output line 1", "more output line 2"]
+
+
+def test_call_with_parser_success(mock_sub, capsys):
+    "Parser returns expected portion of check_output's output"
+
+    output = mock_sub.parse_output(second_line_parser, ['hello', 'world'])
+
+    mock_sub._subprocess.check_output.assert_called_with(["hello", "world"], text=True)
+    assert output == "more output line 2"
+
+
+def test_call_with_parser_error(mock_sub, capsys):
+    "Parser errors on output from check_output"
+
+    with pytest.raises(
+            CommandOutputParseError,
+            match="Unable to parse command output: Input does not contain 3 lines"
+    ):
+        mock_sub.parse_output(third_line_parser, ['hello', 'world'])
+
+    mock_sub._subprocess.check_output.assert_called_with(["hello", "world"], text=True)
+    expected_output = (
+        "\n"
+        "Command Output Parsing Error:\n"
+        "    Input does not contain 3 lines\n"
+        "Command:\n"
+        "    hello world\n"
+        "Command Output:\n"
+        "    some output line 1\n"
+        "    more output line 2\n"
+    )
+    assert capsys.readouterr().out == expected_output
+
+
+@pytest.mark.parametrize(
+    "in_kwargs, kwargs",
+    [
+        ({}, {'text': True}),
+        ({'text': True}, {'text': True}),
+        ({'text': False}, {'text': False}),
+        ({'universal_newlines': False}, {'universal_newlines': False}),
+        ({'universal_newlines': True}, {'universal_newlines': True}),
+    ]
+)
+def test_text_eq_true_default_overriding(mock_sub, in_kwargs, kwargs):
+    "if text or universal_newlines is explicitly provided, those should override text=true default"
+
+    mock_sub.parse_output(splitlines_parser, ['hello', 'world'], **in_kwargs)
+    mock_sub._subprocess.check_output.assert_called_with(["hello", "world"], **kwargs)
diff --git a/tests/integrations/subprocess/test_Subprocess__run.py b/tests/integrations/subprocess/test_Subprocess__run.py
index 673eff4e..c408be4f 100644
--- a/tests/integrations/subprocess/test_Subprocess__run.py
+++ b/tests/integrations/subprocess/test_Subprocess__run.py
@@ -11,7 +11,7 @@ def test_call(mock_sub, capsys):
 
     mock_sub.run(['hello', 'world'])
 
-    mock_sub._subprocess.run.assert_called_with(['hello', 'world'])
+    mock_sub._subprocess.run.assert_called_with(['hello', 'world'], text=True)
     assert capsys.readouterr().out == ""
 
 
@@ -34,7 +34,8 @@ def test_call_with_path_arg(mock_sub, capsys, tmp_path):
 
     mock_sub._subprocess.run.assert_called_with(
         ['hello', os.fsdecode(tmp_path / 'location')],
-        cwd=os.fsdecode(tmp_path / 'cwd')
+        cwd=os.fsdecode(tmp_path / 'cwd'),
+        text=True,
     )
     assert capsys.readouterr().out == ""
 
@@ -45,7 +46,7 @@ def test_debug_call(mock_sub, capsys):
 
     mock_sub.run(['hello', 'world'])
 
-    mock_sub._subprocess.run.assert_called_with(['hello', 'world'])
+    mock_sub._subprocess.run.assert_called_with(['hello', 'world'], text=True)
     assert capsys.readouterr().out == (
         "\n"
         ">>> Running Command:\n"
@@ -63,7 +64,7 @@ def test_debug_call_with_env(mock_sub, capsys):
     merged_env = mock_sub.command.os.environ.copy()
     merged_env.update(env)
 
-    mock_sub._subprocess.run.assert_called_with(['hello', 'world'], env=merged_env)
+    mock_sub._subprocess.run.assert_called_with(['hello', 'world'], env=merged_env, text=True)
 
     expected_output = (
         "\n"
@@ -82,7 +83,7 @@ def test_deep_debug_call(mock_sub, capsys):
 
     mock_sub.run(["hello", "world"])
 
-    mock_sub._subprocess.run.assert_called_with(["hello", "world"])
+    mock_sub._subprocess.run.assert_called_with(["hello", "world"], text=True)
 
     expected_output = (
         "\n"
@@ -111,7 +112,7 @@ def test_deep_debug_call_with_env(mock_sub, capsys):
     merged_env = mock_sub.command.os.environ.copy()
     merged_env.update(env)
 
-    mock_sub._subprocess.run.assert_called_with(["hello", "world"], env=merged_env)
+    mock_sub._subprocess.run.assert_called_with(["hello", "world"], env=merged_env, text=True)
 
     expected_output = (
         "\n"
@@ -160,3 +161,20 @@ def test_calledprocesserror_exception_logging(mock_sub, capsys):
     )
 
     assert capsys.readouterr().out == expected_output
+
+
+@pytest.mark.parametrize(
+    "in_kwargs, kwargs",
+    [
+        ({}, {'text': True}),
+        ({'text': True}, {'text': True}),
+        ({'text': False}, {'text': False}),
+        ({'universal_newlines': False}, {'universal_newlines': False}),
+        ({'universal_newlines': True}, {'universal_newlines': True}),
+    ]
+)
+def test_text_eq_true_default_overriding(mock_sub, in_kwargs, kwargs):
+    "if text or universal_newlines is explicitly provided, those should override text=true default"
+
+    mock_sub.run(['hello', 'world'], **in_kwargs)
+    mock_sub._subprocess.run.assert_called_with(["hello", "world"], **kwargs)
diff --git a/tests/integrations/subprocess/test_ensure_str.py b/tests/integrations/subprocess/test_ensure_str.py
new file mode 100644
index 00000000..1f6f9584
--- /dev/null
+++ b/tests/integrations/subprocess/test_ensure_str.py
@@ -0,0 +1,15 @@
+import pytest
+
+from briefcase.integrations.subprocess import ensure_str
+
+
+@pytest.mark.parametrize(
+    "indata, output",
+    [
+        ("a string 1\na string 2", "a string 1\na string 2"),
+        (b"some bytes 1\nsome bytes 2", "some bytes 1\nsome bytes 2"),
+        (1024, "1024"),
+    ]
+)
+def test_ensure_str(indata, output):
+    assert ensure_str(indata) == output
diff --git a/tests/integrations/subprocess/test_json_parser.py b/tests/integrations/subprocess/test_json_parser.py
new file mode 100644
index 00000000..9aa57af1
--- /dev/null
+++ b/tests/integrations/subprocess/test_json_parser.py
@@ -0,0 +1,26 @@
+import pytest
+
+from briefcase.integrations.subprocess import ParseError, json_parser
+
+
+@pytest.mark.parametrize(
+    "data, output",
+    [
+        ('{"key": "value"}', {"key": "value"}),
+        (b'{"key": "value"}', {"key": "value"}),
+    ]
+)
+def test_json_parser_success(data, output):
+    assert json_parser(data) == output
+
+
+@pytest.mark.parametrize(
+    "data",
+    (
+        'This is a prologue in my JSON output :( \n\n{"key": "value"}',
+        b'This is a prologue in my JSON output :( \n\n{"key": "value"}'
+    )
+)
+def test_json_parser_fail(data):
+    with pytest.raises(ParseError, match="Failed to parse output as JSON:"):
+        json_parser(data)
diff --git a/tests/integrations/xcode/test_ensure_xcode_is_installed.py b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
index feca4efe..ec77cb35 100644
--- a/tests/integrations/xcode/test_ensure_xcode_is_installed.py
+++ b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
@@ -69,7 +69,6 @@ def test_exists_but_command_line_tools_selected(xcode):
     command.subprocess.check_output.assert_called_once_with(
         ['xcodebuild', '-version'],
         stderr=subprocess.STDOUT,
-        universal_newlines=True,
     )
 
 
@@ -92,7 +91,6 @@ def test_exists_but_corrupted(xcode):
     command.subprocess.check_output.assert_called_once_with(
         ['xcodebuild', '-version'],
         stderr=subprocess.STDOUT,
-        universal_newlines=True,
     )
 
 
@@ -108,7 +106,6 @@ def test_installed_no_minimum_version(xcode):
     command.subprocess.check_output.assert_called_once_with(
         ['xcodebuild', '-version'],
         stderr=subprocess.STDOUT,
-        universal_newlines=True,
     )
 
 
@@ -131,7 +128,6 @@ def test_installed_extra_output(capsys, xcode):
     command.subprocess.check_output.assert_called_once_with(
         ['xcodebuild', '-version'],
         stderr=subprocess.STDOUT,
-        universal_newlines=True,
     )
 
     # No warning generated.
@@ -205,12 +201,10 @@ def test_installed_with_minimum_version_success(min_version, version, capsys, xc
             mock.call(
                 ['xcode-select', '-p'],
                 stderr=subprocess.STDOUT,
-                universal_newlines=True,
             ),
             mock.call(
                 ['xcodebuild', '-version'],
                 stderr=subprocess.STDOUT,
-                universal_newlines=True,
             ),
         ],
         any_order=False,
@@ -250,7 +244,6 @@ def test_installed_with_minimum_version_failure(min_version, version, xcode):
     command.subprocess.check_output.assert_called_once_with(
         ['xcodebuild', '-version'],
         stderr=subprocess.STDOUT,
-        universal_newlines=True,
     )
 
 
@@ -271,7 +264,6 @@ def test_unexpected_version_output(capsys, xcode):
     command.subprocess.check_output.assert_called_once_with(
         ['xcodebuild', '-version'],
         stderr=subprocess.STDOUT,
-        universal_newlines=True,
     )
 
     # ...but stdout contains a warning
diff --git a/tests/integrations/xcode/test_get_device_state.py b/tests/integrations/xcode/test_get_device_state.py
index 8ce7e771..b0c3b3fa 100644
--- a/tests/integrations/xcode/test_get_device_state.py
+++ b/tests/integrations/xcode/test_get_device_state.py
@@ -5,9 +5,19 @@ from unittest import mock
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError
+from briefcase.integrations.subprocess import Subprocess
 from briefcase.integrations.xcode import DeviceState, get_device_state
 
 
+@pytest.fixture
+def command():
+    command = mock.MagicMock()
+    command.subprocess = Subprocess(command)
+    command.subprocess._subprocess = mock.MagicMock()
+    command.subprocess.check_output = mock.MagicMock()
+    return command
+
+
 def simctl_result(name):
     """Load a simctl result file from the sample directory, and return the content"""
     filename = Path(__file__).parent / 'simctl' / f'{name}.json'
@@ -15,30 +25,35 @@ def simctl_result(name):
         return f.read()
 
 
-def test_simctl_missing():
+def test_simctl_missing(command):
     "If simctl is missing or fails to start, an exception is raised."
-    command = mock.MagicMock()
     command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
         cmd=['xcrun', 'simctl', 'list', '-j'],
         returncode=1
     )
 
-    with pytest.raises(BriefcaseCommandError):
+    with pytest.raises(BriefcaseCommandError, match="Unable to run xcrun simctl."):
+        get_device_state(command, '2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D')
+
+
+def test_simctl_output_parse_error(command):
+    "If parsing simctl JSON output fails, an exception is raised"
+    command.subprocess.check_output.return_value = "this is not JSON"
+
+    with pytest.raises(BriefcaseCommandError, match="Unable to parse output of xcrun simctl"):
         get_device_state(command, '2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D')
 
 
-def test_unknown_device():
+def test_unknown_device(command):
     "If you ask for an invalid device UDID, an exception is raised."
-    command = mock.MagicMock()
     command.subprocess.check_output.return_value = simctl_result('no-devices')
 
     with pytest.raises(BriefcaseCommandError):
         get_device_state(command, 'dead-beef-dead-beef')
 
 
-def test_known_device_booted():
+def test_known_device_booted(command):
     "A valid, booted device can be inspected"
-    command = mock.MagicMock()
     command.subprocess.check_output.return_value = simctl_result('single-device-booted')
 
     state = get_device_state(command, '2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D')
@@ -46,9 +61,8 @@ def test_known_device_booted():
     assert state == DeviceState.BOOTED
 
 
-def test_known_device_shutdown():
+def test_known_device_shutdown(command):
     "A valid, shut down device can be inspected"
-    command = mock.MagicMock()
     command.subprocess.check_output.return_value = simctl_result('single-device-shutdown')
 
     state = get_device_state(command, '2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D')
@@ -56,9 +70,8 @@ def test_known_device_shutdown():
     assert state == DeviceState.SHUTDOWN
 
 
-def test_known_device_shutting_down():
+def test_known_device_shutting_down(command):
     "A valid device that is shutting down can be inspected"
-    command = mock.MagicMock()
     command.subprocess.check_output.return_value = simctl_result('single-device-shutting-down')
 
     state = get_device_state(command, '2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D')
@@ -66,9 +79,8 @@ def test_known_device_shutting_down():
     assert state == DeviceState.SHUTTING_DOWN
 
 
-def test_known_device_unknown_status():
+def test_known_device_unknown_status(command):
     "If simctl returns something unexpected as status, we can recover"
-    command = mock.MagicMock()
     command.subprocess.check_output.return_value = simctl_result('single-device-unknown')
 
     state = get_device_state(command, '2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D')
diff --git a/tests/integrations/xcode/test_get_simulators.py b/tests/integrations/xcode/test_get_simulators.py
index ec1fe9d3..a9760ba0 100644
--- a/tests/integrations/xcode/test_get_simulators.py
+++ b/tests/integrations/xcode/test_get_simulators.py
@@ -6,9 +6,19 @@ from unittest import mock
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError
+from briefcase.integrations.subprocess import Subprocess
 from briefcase.integrations.xcode import get_simulators
 
 
+@pytest.fixture
+def command():
+    command = mock.MagicMock()
+    command.subprocess = Subprocess(command)
+    command.subprocess._subprocess = mock.MagicMock()
+    command.subprocess.check_output = mock.MagicMock()
+    return command
+
+
 @pytest.fixture
 def simulator(tmp_path):
     "Create a dummy location for the simulator"
@@ -24,9 +34,8 @@ def simctl_result(name):
         return f.read()
 
 
-def test_simulator_is_missing(tmp_path):
+def test_simulator_is_missing(command, tmp_path):
     "If the simulator is not installed, a prompt is shown to the user"
-    command = mock.MagicMock()
     command.subprocess.check_output.return_value = simctl_result('no-runtimes')
 
     simulators = get_simulators(
@@ -42,21 +51,27 @@ def test_simulator_is_missing(tmp_path):
     assert simulators == {}
 
 
-def test_simctl_missing(simulator):
+def test_simctl_missing(command, simulator):
     "If simctl is missing or fails to start, an exception is raised."
-    command = mock.MagicMock()
     command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
         cmd=['xcrun', 'simctl', 'list', '-j'],
         returncode=1
     )
 
-    with pytest.raises(BriefcaseCommandError):
+    with pytest.raises(BriefcaseCommandError, match="Unable to run xcrun simctl."):
+        get_simulators(command, 'iOS', simulator_location=simulator)
+
+
+def test_simctl_output_parse_error(command, simulator):
+    "If parsing simctl JSON output fails, an exception is raised"
+    command.subprocess.check_output.return_value = "this is not JSON"
+
+    with pytest.raises(BriefcaseCommandError, match="Unable to parse output of xcrun simctl"):
         get_simulators(command, 'iOS', simulator_location=simulator)
 
 
-def test_no_runtimes(simulator):
+def test_no_runtimes(command, simulator):
     "If there are no runtimes available, no simulators will be found"
-    command = mock.MagicMock()
     command.subprocess.check_output.return_value = simctl_result('no-runtimes')
 
     simulators = get_simulators(
@@ -71,9 +86,8 @@ def test_no_runtimes(simulator):
     command.input.assert_not_called()
 
 
-def test_single_iOS_runtime(simulator):
+def test_single_iOS_runtime(command, simulator):
     "If an iOS version is installed, devices can be found"
-    command = mock.MagicMock()
     command.subprocess.check_output.return_value = simctl_result('iOS-13.2-only')
 
     simulators = get_simulators(
@@ -101,9 +115,8 @@ def test_single_iOS_runtime(simulator):
     command.input.assert_not_called()
 
 
-def test_watchOS_runtime(simulator):
+def test_watchOS_runtime(command, simulator):
     "Runtimes other than iOS can be requested."
-    command = mock.MagicMock()
     command.subprocess.check_output.return_value = simctl_result('iOS-13.2-only')
 
     simulators = get_simulators(
@@ -125,9 +138,8 @@ def test_watchOS_runtime(simulator):
     command.input.assert_not_called()
 
 
-def test_multiple_iOS_runtime(simulator):
+def test_multiple_iOS_runtime(command, simulator):
     "If multiple iOS versions are installed, this will be reflected in results"
-    command = mock.MagicMock()
     command.subprocess.check_output.return_value = simctl_result('multiple-iOS-versions')
 
     simulators = get_simulators(
@@ -173,9 +185,8 @@ def test_multiple_iOS_runtime(simulator):
     command.input.assert_not_called()
 
 
-def test_unknown_runtime(simulator):
+def test_unknown_runtime(command, simulator):
     "If an unknown runtime is requested, no devices will be found"
-    command = mock.MagicMock()
     command.subprocess.check_output.return_value = simctl_result('multiple-iOS-versions')
 
     simulators = get_simulators(
@@ -190,11 +201,10 @@ def test_unknown_runtime(simulator):
     command.input.assert_not_called()
 
 
-def test_alternate_format(simulator):
+def test_alternate_format(command, simulator):
     "The alternate format for device versions can be parsed"
     mock_input = mock.MagicMock()
 
-    command = mock.MagicMock()
     command.subprocess.check_output.return_value = simctl_result('alternate-format')
 
     simulators = get_simulators(
diff --git a/tests/platforms/linux/appimage/test_build.py b/tests/platforms/linux/appimage/test_build.py
index 01134b27..86619163 100644
--- a/tests/platforms/linux/appimage/test_build.py
+++ b/tests/platforms/linux/appimage/test_build.py
@@ -130,7 +130,8 @@ def test_build_appimage(build_command, first_app, tmp_path):
             'VERSION': '0.0.1',
         },
         check=True,
-        cwd=os.fsdecode(tmp_path / 'linux')
+        cwd=os.fsdecode(tmp_path / 'linux'),
+        text=True,
     )
     # Binary is marked executable
     build_command.os.chmod.assert_called_with(
@@ -170,7 +171,8 @@ def test_build_failure(build_command, first_app, tmp_path):
             'VERSION': '0.0.1',
         },
         check=True,
-        cwd=os.fsdecode(tmp_path / 'linux')
+        cwd=os.fsdecode(tmp_path / 'linux'),
+        text=True,
     )
 
     # chmod isn't invoked if the binary wasn't created.
@@ -211,7 +213,8 @@ def test_build_appimage_with_docker(build_command, first_app, tmp_path):
             "--deploy-deps-only", "/app/appimage/First App/First App.AppDir/usr/app_packages/secondlib",
         ],
         check=True,
-        cwd=os.fsdecode(tmp_path / 'linux')
+        cwd=os.fsdecode(tmp_path / 'linux'),
+        text=True,
     )
     # Binary is marked executable
     build_command.os.chmod.assert_called_with(
diff --git a/tests/platforms/macOS/app/test_signing.py b/tests/platforms/macOS/app/test_signing.py
index b63998f5..510f5d69 100644
--- a/tests/platforms/macOS/app/test_signing.py
+++ b/tests/platforms/macOS/app/test_signing.py
@@ -85,7 +85,7 @@ def mock_codesign(results):
             raise subprocess.CalledProcessError(
                 returncode=1,
                 cmd=args,
-                stderr=(f'{args[1]}: {result}').encode('utf-8')
+                stderr=f'{args[1]}: {result}'
             )
 
     return _codesign