diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..095dec1 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Maintainer review is required for changes that affect the app or its delivery. +* @hnduy910 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1f01188 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/hnduy910/NetFlow/security/advisories/new + about: Please use a private security report instead of a public issue. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6fff16c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: monthly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a2fe64..42478d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: workflow_dispatch: +permissions: + contents: read + jobs: validate: runs-on: macos-15 @@ -22,3 +25,12 @@ jobs: -sdk iphonesimulator \ CODE_SIGNING_ALLOWED=NO \ build + - name: Run XCTest suite + run: | + xcodebuild \ + -project NetFlow.xcodeproj \ + -scheme NetFlow \ + -configuration Debug \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \ + CODE_SIGNING_ALLOWED=NO \ + test diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..816240e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,35 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "23 3 * * 1" + workflow_dispatch: + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze Swift + runs-on: macos-15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: swift + build-mode: autobuild + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Analyze + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba39be7..678bd60 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,9 @@ name: Build unsigned release IPA on: - push: + workflow_run: + workflows: [iOS CI] + types: [completed] branches: [main] workflow_dispatch: @@ -10,9 +12,17 @@ permissions: jobs: release: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') runs-on: macos-15 + concurrency: + group: netflow-release-${{ github.event.workflow_run.head_sha || github.sha }} + cancel-in-progress: false steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} - name: Resolve release version id: meta @@ -62,14 +72,15 @@ jobs: if: steps.meta.outputs.exists != 'true' env: GH_TOKEN: ${{ github.token }} + COMMIT: ${{ github.event.workflow_run.head_sha || github.sha }} run: | TAG="${{ steps.meta.outputs.tag }}" VERSION="${{ steps.meta.outputs.version }}" BUILD="${{ steps.meta.outputs.build }}" - git tag "${TAG}" "$GITHUB_SHA" + git tag "${TAG}" "$COMMIT" git push origin "${TAG}" gh release create "${TAG}" \ - --target "$GITHUB_SHA" \ + --target "$COMMIT" \ --title "NetFlow ${VERSION} (Build ${BUILD})" \ --generate-notes \ "NetFlow_Universal_${TAG}.ipa" \ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5035321 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.DS_Store + +# Xcode user and build state +build/ +.build/ +DerivedData/ +xcuserdata/ +*.xcuserstate + +# Local distribution artifacts belong in GitHub Releases, not source history. +*.ipa +*.ipa.sha256 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1134957..e68b6d3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,22 @@ Thank you for taking the time to improve NetFlow. Bug reports, documentation fix NetFlow uses Apple's native frameworks and does not require CocoaPods or third-party packages. Network values, Wi-Fi information, background refresh, and entitlements can behave differently on a Simulator; validate device-specific behavior on a physical device when needed. +## Automated validation + +Run the static checks and XCTest suite before opening a pull request: + +```bash +bash Tools/check_project.sh +xcodebuild \ + -project NetFlow.xcodeproj \ + -scheme NetFlow \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \ + CODE_SIGNING_ALLOWED=NO \ + test +``` + +The same checks run on GitHub Actions for pushes to `main` and pull requests. The CI workflow must pass before the release workflow can publish an unsigned IPA. + ## Development workflow 1. Fork the repository and create a focused branch from `main`. @@ -28,6 +44,8 @@ NetFlow uses Apple's native frameworks and does not require CocoaPods or third-p 6. Update the README or screenshots when a user-visible feature changes. 7. Open a pull request with a concise summary, testing notes, and screenshots for UI changes. +Please keep pull requests focused. A maintainer review is required for changes to app behavior, security-sensitive code, or release automation. + ## Pull request checklist - [ ] The change is limited to the stated purpose. @@ -40,4 +58,3 @@ NetFlow uses Apple's native frameworks and does not require CocoaPods or third-p ## Bug reports and feature requests Include the device or Simulator model, iOS version, Xcode version, exact steps to reproduce, and any relevant console output. Redact identifiers and private data before attaching logs or screenshots. - diff --git a/NetFlow.xcodeproj/project.pbxproj b/NetFlow.xcodeproj/project.pbxproj index c2e0836..a25960f 100644 --- a/NetFlow.xcodeproj/project.pbxproj +++ b/NetFlow.xcodeproj/project.pbxproj @@ -1,153 +1,616 @@ // !$*UTF8*$! { archiveVersion = 1; - classes = {}; - objectVersion = 56; + classes = { + }; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ - A20000000000000000000001 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000011 /* Assets.xcassets */; }; - A10000000000000000000001 /* SystemCapabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000011 /* SystemCapabilities.swift */; }; - A10000000000000000000002 /* SystemCapabilitiesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000012 /* SystemCapabilitiesService.swift */; }; - A10000000000000000000003 /* SystemCapabilitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000013 /* SystemCapabilitiesView.swift */; }; - A10000000000000000000004 /* AppChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000014 /* AppChrome.swift */; }; - 40B986A4B04DA1316E6B0134 /* AppStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110D6E0C411424287D6B4383 /* AppStore.swift */; }; - 521902CA3B6BBBBC62DB2FA7 /* NetFlowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66562ACE246E64C5DC7BAB8 /* NetFlowApp.swift */; }; - 8A359A8D84E62D35FDB66F1B /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC9E8391260445C27D7B3B6 /* AppSettings.swift */; }; - 542DF0191ACA2810C131B99D /* DailyUsageRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 441E52EC685FFBB032603AFE /* DailyUsageRecord.swift */; }; - D2270CD05B2FC1D0A67EB411 /* DataPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93ABC57C6E92AEA46AC41EEA /* DataPlan.swift */; }; - 63D66C18AB73E0DFC9C660E4 /* NetworkModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9838A47C52DE0F97CABAA5A /* NetworkModels.swift */; }; - BC9527A9ADE6C0FC9B6A5634 /* Formatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0952488F06B9BB27E4C1805B /* Formatters.swift */; }; - 5FAC6EEEB74132BBCD72AAAB /* NetworkInterfaceReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBF058E2145E41C77EB776F /* NetworkInterfaceReader.swift */; }; - 837828955A4C7990199751CB /* PDFReportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A871A031C18E978F340BCBB3 /* PDFReportService.swift */; }; - A05DF6A196B78DA6CAAF43DD /* PersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CD0413EDA45418DC162C1CF /* PersistenceService.swift */; }; - 1CA042ED5DD90E6E6B21E4AE /* UsageTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F28136BB7EE90119A071A64F /* UsageTracker.swift */; }; - 752CB43A93A37FC248AE24CE /* UsageCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8CC3259B859AD4BAA7DCD8 /* UsageCard.swift */; }; - 40D1A72FF577054CC672548B /* DataPlanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A88F37D24373DB0F8240DD3 /* DataPlanView.swift */; }; - A884142D17D4815F9779EDCC /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5659AAB40BAC31935CB6B45A /* HistoryView.swift */; }; - 7C633070DB01638B975AE4E9 /* OverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7030CDE8DAA0697FD42DB49 /* OverviewView.swift */; }; - ECA2504668DE0828127450F0 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2200878E94C30216487A4A30 /* RootTabView.swift */; }; - 4AB6E687D2B790F2BC3AD3A8 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD3576ED0D323BFF727B1AF /* SettingsView.swift */; }; - BC489C0029FE233D12E3211D /* en.lproj/Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDB39C484F75B9A3E177C1A9; }; - 17ACAC1695DCD4C77F44DD8E /* vi.lproj/Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0684AA56AE94CD2625756DAA; }; + 1689C539C8CBEF3728665E2F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5B8BA599DA3470763B88F4A0 /* Assets.xcassets */; }; + 228EDE607E5DCD36B97B7E93 /* AppStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFE0245990F4E97984F02AF /* AppStore.swift */; }; + 26C29F1B8D824052CD733D2A /* SystemCapabilitiesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C252D38C0C921E6F8C602986 /* SystemCapabilitiesService.swift */; }; + 304FF9D84A862007C8B4BE8A /* NetFlowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BC518B22CE0DFB5A43D67C /* NetFlowApp.swift */; }; + 35A16B36A8168AAD7A401C43 /* UsageTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AF2D6A7CD44507FF7727F50 /* UsageTracker.swift */; }; + 51D6A1F5AFEEEADB713D3381 /* NetworkInterfaceReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837941D8E539720585C89897 /* NetworkInterfaceReader.swift */; }; + 5C933FA47C0C6F23592A5315 /* AppChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4994BA349BD59EA0D48A4A4B /* AppChrome.swift */; }; + 6315AA4CE266139EB971B651 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2562289781F7E1603D3C2AB0 /* HistoryView.swift */; }; + 69FB51483FDE63DD07E2F7E6 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16EECC3D3B5E47AA809473B0 /* AppSettings.swift */; }; + 79451DBE4DC1BEFC54A6E2DB /* SystemCapabilitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8DB3C294577ABA708A3489 /* SystemCapabilitiesView.swift */; }; + 79A31E915726C531395B9598 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6D85363507F81D69F4FC8 /* SettingsView.swift */; }; + 80FB2ED1EA3FC610E3905FC4 /* PersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70E3004C7773DF796570D04 /* PersistenceService.swift */; }; + 87E8332964C3C8A52BD69A32 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = CDD6E81AB202F0850AED19C5 /* Localizable.strings */; }; + 88BF570D4A7666A3BCD2E88D /* PDFReportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332342DBF29B01143994781F /* PDFReportService.swift */; }; + 924429FEE7F54C5BE8277EA3 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B0CBB8AD62A721DFC42A2B /* RootTabView.swift */; }; + 988D23A017B7832255EDE20D /* NetworkModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E95E0426B5F63995E512D2 /* NetworkModels.swift */; }; + 9BB3906062A78AC726C06815 /* SystemCapabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 119D11CFEC71310FDFED75B7 /* SystemCapabilities.swift */; }; + A10B883F3A19B91ACE2867B8 /* UsageTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BAEAFF52E03014D19F00C4D /* UsageTrackerTests.swift */; }; + A2B6DE404ACF641034CB653E /* Formatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615B71AD36A303BCADBA89C3 /* Formatters.swift */; }; + B537058BA665D9068531C9F9 /* AppStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF09890C3042EEB451347E6 /* AppStoreTests.swift */; }; + B6451560C472B6D274249D24 /* OverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FC4E9DDBF5B6A4B13A7A773 /* OverviewView.swift */; }; + B83C0AEBE464551AB8F6EB0C /* DataPlanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD889CE2D0915B333F9A14FC /* DataPlanView.swift */; }; + CF81A868D086A2FC4D7E6438 /* DailyUsageRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A46B0F637F7F40B20C66453 /* DailyUsageRecord.swift */; }; + D11BDB9CC517858763953BEE /* DataPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04396C12A917A84D9C1265CB /* DataPlanTests.swift */; }; + D98774FAA9EDF290950EDB5B /* DataPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3DB9D73B32BEE1C1ED7842C /* DataPlan.swift */; }; + DB6DDE1D98A3C160AE48F694 /* PersistenceServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7864E5824362DDCBBC4C9DB6 /* PersistenceServiceTests.swift */; }; + EAB7617EA73E7CFD22E533F3 /* UsageCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 261C5DA926135BB340054391 /* UsageCard.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 9CAC58420B3BCB933D1ABD3A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C52E1182A4D9012D6EB78D7E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 86D3F0629756097A5955B6A5; + remoteInfo = NetFlow; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ - A20000000000000000000011 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "NetFlow/Resources/Assets.xcassets"; sourceTree = ""; }; - A10000000000000000000011 /* SystemCapabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Models/SystemCapabilities.swift"; sourceTree = ""; }; - A10000000000000000000012 /* SystemCapabilitiesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Services/SystemCapabilitiesService.swift"; sourceTree = ""; }; - A10000000000000000000013 /* SystemCapabilitiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Views/System/SystemCapabilitiesView.swift"; sourceTree = ""; }; - A10000000000000000000014 /* AppChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Views/Components/AppChrome.swift"; sourceTree = ""; }; - 110D6E0C411424287D6B4383 /* AppStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/App/AppStore.swift"; sourceTree = ""; }; - E66562ACE246E64C5DC7BAB8 /* NetFlowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/App/NetFlowApp.swift"; sourceTree = ""; }; - 2BC9E8391260445C27D7B3B6 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Models/AppSettings.swift"; sourceTree = ""; }; - 441E52EC685FFBB032603AFE /* DailyUsageRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Models/DailyUsageRecord.swift"; sourceTree = ""; }; - 93ABC57C6E92AEA46AC41EEA /* DataPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Models/DataPlan.swift"; sourceTree = ""; }; - B9838A47C52DE0F97CABAA5A /* NetworkModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Models/NetworkModels.swift"; sourceTree = ""; }; - 0952488F06B9BB27E4C1805B /* Formatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Services/Formatters.swift"; sourceTree = ""; }; - 1CBF058E2145E41C77EB776F /* NetworkInterfaceReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Services/NetworkInterfaceReader.swift"; sourceTree = ""; }; - A871A031C18E978F340BCBB3 /* PDFReportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Services/PDFReportService.swift"; sourceTree = ""; }; - 7CD0413EDA45418DC162C1CF /* PersistenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Services/PersistenceService.swift"; sourceTree = ""; }; - F28136BB7EE90119A071A64F /* UsageTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Services/UsageTracker.swift"; sourceTree = ""; }; - 1F8CC3259B859AD4BAA7DCD8 /* UsageCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Views/Components/UsageCard.swift"; sourceTree = ""; }; - 9A88F37D24373DB0F8240DD3 /* DataPlanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Views/DataPlan/DataPlanView.swift"; sourceTree = ""; }; - 5659AAB40BAC31935CB6B45A /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Views/History/HistoryView.swift"; sourceTree = ""; }; - E7030CDE8DAA0697FD42DB49 /* OverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Views/Overview/OverviewView.swift"; sourceTree = ""; }; - 2200878E94C30216487A4A30 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Views/RootTabView.swift"; sourceTree = ""; }; - 1FD3576ED0D323BFF727B1AF /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetFlow/Views/Settings/SettingsView.swift"; sourceTree = ""; }; - BDB39C484F75B9A3E177C1A9 /* en.lproj/Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = "NetFlow/Resources/en.lproj/Localizable.strings"; sourceTree = ""; }; - 0684AA56AE94CD2625756DAA /* vi.lproj/Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = "NetFlow/Resources/vi.lproj/Localizable.strings"; sourceTree = ""; }; - D00000000000000000000001 /* NetFlow.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NetFlow.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 04396C12A917A84D9C1265CB /* DataPlanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPlanTests.swift; sourceTree = ""; }; + 04BC518B22CE0DFB5A43D67C /* NetFlowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetFlowApp.swift; sourceTree = ""; }; + 0A14E0FC54CB91D7EFBE6288 /* NetFlow.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NetFlow.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 119D11CFEC71310FDFED75B7 /* SystemCapabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemCapabilities.swift; sourceTree = ""; }; + 16EECC3D3B5E47AA809473B0 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + 1FF09890C3042EEB451347E6 /* AppStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreTests.swift; sourceTree = ""; }; + 2562289781F7E1603D3C2AB0 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + 261C5DA926135BB340054391 /* UsageCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageCard.swift; sourceTree = ""; }; + 2B8DB3C294577ABA708A3489 /* SystemCapabilitiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemCapabilitiesView.swift; sourceTree = ""; }; + 30AFA73F1D55916615E09DEE /* NetFlowTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = NetFlowTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 332342DBF29B01143994781F /* PDFReportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFReportService.swift; sourceTree = ""; }; + 4994BA349BD59EA0D48A4A4B /* AppChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppChrome.swift; sourceTree = ""; }; + 5B8BA599DA3470763B88F4A0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 615B71AD36A303BCADBA89C3 /* Formatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatters.swift; sourceTree = ""; }; + 6AF2D6A7CD44507FF7727F50 /* UsageTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageTracker.swift; sourceTree = ""; }; + 6BAEAFF52E03014D19F00C4D /* UsageTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageTrackerTests.swift; sourceTree = ""; }; + 6CBC315C8A1D15CCEBDF544B /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 6FC4E9DDBF5B6A4B13A7A773 /* OverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewView.swift; sourceTree = ""; }; + 7864E5824362DDCBBC4C9DB6 /* PersistenceServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceServiceTests.swift; sourceTree = ""; }; + 7A46B0F637F7F40B20C66453 /* DailyUsageRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyUsageRecord.swift; sourceTree = ""; }; + 837941D8E539720585C89897 /* NetworkInterfaceReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkInterfaceReader.swift; sourceTree = ""; }; + 84E95E0426B5F63995E512D2 /* NetworkModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkModels.swift; sourceTree = ""; }; + B70E3004C7773DF796570D04 /* PersistenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceService.swift; sourceTree = ""; }; + C252D38C0C921E6F8C602986 /* SystemCapabilitiesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemCapabilitiesService.swift; sourceTree = ""; }; + D1F27CFF0A04A2D93DE154D1 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; + D7E6D85363507F81D69F4FC8 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + E3DB9D73B32BEE1C1ED7842C /* DataPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPlan.swift; sourceTree = ""; }; + E6DFB8D2155D261F709B0160 /* NetFlow.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NetFlow.entitlements; sourceTree = ""; }; + E8B0CBB8AD62A721DFC42A2B /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = ""; }; + ECFE0245990F4E97984F02AF /* AppStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStore.swift; sourceTree = ""; }; + F2E696249F02CA3009437B95 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + FD889CE2D0915B333F9A14FC /* DataPlanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPlanView.swift; sourceTree = ""; }; /* End PBXFileReference section */ -/* Begin PBXFrameworksBuildPhase section */ - D00000000000000000000007 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; -/* End PBXFrameworksBuildPhase section */ - /* Begin PBXGroup section */ - D00000000000000000000003 = {isa = PBXGroup; children = ( - A20000000000000000000011 /* Assets.xcassets */, - A10000000000000000000011 /* SystemCapabilities.swift */, - A10000000000000000000012 /* SystemCapabilitiesService.swift */, - A10000000000000000000013 /* SystemCapabilitiesView.swift */, - A10000000000000000000014 /* AppChrome.swift */, - 110D6E0C411424287D6B4383 /* AppStore.swift */, - E66562ACE246E64C5DC7BAB8 /* NetFlowApp.swift */, - 2BC9E8391260445C27D7B3B6 /* AppSettings.swift */, - 441E52EC685FFBB032603AFE /* DailyUsageRecord.swift */, - 93ABC57C6E92AEA46AC41EEA /* DataPlan.swift */, - B9838A47C52DE0F97CABAA5A /* NetworkModels.swift */, - 0952488F06B9BB27E4C1805B /* Formatters.swift */, - 1CBF058E2145E41C77EB776F /* NetworkInterfaceReader.swift */, - A871A031C18E978F340BCBB3 /* PDFReportService.swift */, - 7CD0413EDA45418DC162C1CF /* PersistenceService.swift */, - F28136BB7EE90119A071A64F /* UsageTracker.swift */, - 1F8CC3259B859AD4BAA7DCD8 /* UsageCard.swift */, - 9A88F37D24373DB0F8240DD3 /* DataPlanView.swift */, - 5659AAB40BAC31935CB6B45A /* HistoryView.swift */, - E7030CDE8DAA0697FD42DB49 /* OverviewView.swift */, - 2200878E94C30216487A4A30 /* RootTabView.swift */, - 1FD3576ED0D323BFF727B1AF /* SettingsView.swift */, - BDB39C484F75B9A3E177C1A9 /* Localizable.strings */, - 0684AA56AE94CD2625756DAA /* Localizable.strings */, - D00000000000000000000004 /* Products */, - ); sourceTree = ""; }; - D00000000000000000000004 /* Products */ = {isa = PBXGroup; children = (D00000000000000000000001 /* NetFlow.app */,); name = Products; sourceTree = ""; }; + 0E71D3F17DB29EA91196B4E3 /* Views */ = { + isa = PBXGroup; + children = ( + E8B0CBB8AD62A721DFC42A2B /* RootTabView.swift */, + 3DEA718E1280CF816E699710 /* Components */, + FF2FA53670FA8EB232C72C9D /* DataPlan */, + E6ACB329F330E2A45E16A263 /* History */, + F3FED09A48EE49B8F4E67649 /* Overview */, + 58D380A64399EA95771F8B6E /* Settings */, + 58E001368DA8468864E47764 /* System */, + ); + path = Views; + sourceTree = ""; + }; + 10ED10B1689C4E325818A770 /* Services */ = { + isa = PBXGroup; + children = ( + 615B71AD36A303BCADBA89C3 /* Formatters.swift */, + 837941D8E539720585C89897 /* NetworkInterfaceReader.swift */, + 332342DBF29B01143994781F /* PDFReportService.swift */, + B70E3004C7773DF796570D04 /* PersistenceService.swift */, + C252D38C0C921E6F8C602986 /* SystemCapabilitiesService.swift */, + 6AF2D6A7CD44507FF7727F50 /* UsageTracker.swift */, + ); + path = Services; + sourceTree = ""; + }; + 1E8FC7483A8ED1A55E10E056 /* App */ = { + isa = PBXGroup; + children = ( + ECFE0245990F4E97984F02AF /* AppStore.swift */, + 04BC518B22CE0DFB5A43D67C /* NetFlowApp.swift */, + ); + path = App; + sourceTree = ""; + }; + 20D5665D58C872F92B29B6B5 /* Models */ = { + isa = PBXGroup; + children = ( + 16EECC3D3B5E47AA809473B0 /* AppSettings.swift */, + 7A46B0F637F7F40B20C66453 /* DailyUsageRecord.swift */, + E3DB9D73B32BEE1C1ED7842C /* DataPlan.swift */, + 84E95E0426B5F63995E512D2 /* NetworkModels.swift */, + 119D11CFEC71310FDFED75B7 /* SystemCapabilities.swift */, + ); + path = Models; + sourceTree = ""; + }; + 3DEA718E1280CF816E699710 /* Components */ = { + isa = PBXGroup; + children = ( + 4994BA349BD59EA0D48A4A4B /* AppChrome.swift */, + 261C5DA926135BB340054391 /* UsageCard.swift */, + ); + path = Components; + sourceTree = ""; + }; + 58D380A64399EA95771F8B6E /* Settings */ = { + isa = PBXGroup; + children = ( + D7E6D85363507F81D69F4FC8 /* SettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 58E001368DA8468864E47764 /* System */ = { + isa = PBXGroup; + children = ( + 2B8DB3C294577ABA708A3489 /* SystemCapabilitiesView.swift */, + ); + path = System; + sourceTree = ""; + }; + B446C7C65AA5A3DF013909CD = { + isa = PBXGroup; + children = ( + B4D47077D765AD8252637B2A /* NetFlow */, + FB3DFDE2D49E863CEAAC7A01 /* NetFlowTests */, + DA5D5D3D8FFB40E8E5137428 /* Products */, + ); + sourceTree = ""; + }; + B4D47077D765AD8252637B2A /* NetFlow */ = { + isa = PBXGroup; + children = ( + F2E696249F02CA3009437B95 /* Info.plist */, + E6DFB8D2155D261F709B0160 /* NetFlow.entitlements */, + 1E8FC7483A8ED1A55E10E056 /* App */, + 20D5665D58C872F92B29B6B5 /* Models */, + E970C288A7EE31C14C95FB3A /* Resources */, + 10ED10B1689C4E325818A770 /* Services */, + 0E71D3F17DB29EA91196B4E3 /* Views */, + ); + path = NetFlow; + sourceTree = ""; + }; + DA5D5D3D8FFB40E8E5137428 /* Products */ = { + isa = PBXGroup; + children = ( + 0A14E0FC54CB91D7EFBE6288 /* NetFlow.app */, + 30AFA73F1D55916615E09DEE /* NetFlowTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + E6ACB329F330E2A45E16A263 /* History */ = { + isa = PBXGroup; + children = ( + 2562289781F7E1603D3C2AB0 /* HistoryView.swift */, + ); + path = History; + sourceTree = ""; + }; + E970C288A7EE31C14C95FB3A /* Resources */ = { + isa = PBXGroup; + children = ( + 5B8BA599DA3470763B88F4A0 /* Assets.xcassets */, + CDD6E81AB202F0850AED19C5 /* Localizable.strings */, + ); + path = Resources; + sourceTree = ""; + }; + F3FED09A48EE49B8F4E67649 /* Overview */ = { + isa = PBXGroup; + children = ( + 6FC4E9DDBF5B6A4B13A7A773 /* OverviewView.swift */, + ); + path = Overview; + sourceTree = ""; + }; + FB3DFDE2D49E863CEAAC7A01 /* NetFlowTests */ = { + isa = PBXGroup; + children = ( + 1FF09890C3042EEB451347E6 /* AppStoreTests.swift */, + 04396C12A917A84D9C1265CB /* DataPlanTests.swift */, + 7864E5824362DDCBBC4C9DB6 /* PersistenceServiceTests.swift */, + 6BAEAFF52E03014D19F00C4D /* UsageTrackerTests.swift */, + ); + path = NetFlowTests; + sourceTree = ""; + }; + FF2FA53670FA8EB232C72C9D /* DataPlan */ = { + isa = PBXGroup; + children = ( + FD889CE2D0915B333F9A14FC /* DataPlanView.swift */, + ); + path = DataPlan; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - D00000000000000000000005 /* NetFlow */ = {isa = PBXNativeTarget; buildConfigurationList = D0000000000000000000000A; buildPhases = (D00000000000000000000006,D00000000000000000000007,D00000000000000000000008); buildRules = (); dependencies = (); name = NetFlow; productName = NetFlow; productReference = D00000000000000000000001; productType = "com.apple.product-type.application"; }; + 19EB3BDA957FAB4A1D180062 /* NetFlowTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 697664B9C5721573A0BEA1CA /* Build configuration list for PBXNativeTarget "NetFlowTests" */; + buildPhases = ( + 91507C4F635DC0682C55A788 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 4EFF36361E95C6722EEA69FD /* PBXTargetDependency */, + ); + name = NetFlowTests; + packageProductDependencies = ( + ); + productName = NetFlowTests; + productReference = 30AFA73F1D55916615E09DEE /* NetFlowTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 86D3F0629756097A5955B6A5 /* NetFlow */ = { + isa = PBXNativeTarget; + buildConfigurationList = 395C976E31A88340EF5335AD /* Build configuration list for PBXNativeTarget "NetFlow" */; + buildPhases = ( + DE5D6BA907B480E5049F052F /* Sources */, + 5F776BFFE2CC54EA8B30E704 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NetFlow; + packageProductDependencies = ( + ); + productName = NetFlow; + productReference = 0A14E0FC54CB91D7EFBE6288 /* NetFlow.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - D00000000000000000000002 /* Project object */ = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1600; TargetAttributes = {D00000000000000000000005 = {CreatedOnToolsVersion = 16.0; }; }; }; buildConfigurationList = D00000000000000000000009; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = (en, Base, vi); mainGroup = D00000000000000000000003; productRefGroup = D00000000000000000000004; projectDirPath = ""; projectRoot = ""; targets = (D00000000000000000000005); }; + C52E1182A4D9012D6EB78D7E /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1500; + TargetAttributes = { + }; + }; + buildConfigurationList = 42784119405FA3D8EA9F81DB /* Build configuration list for PBXProject "NetFlow" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + vi, + ); + mainGroup = B446C7C65AA5A3DF013909CD; + minimizedProjectReferenceProxies = 1; + productRefGroup = DA5D5D3D8FFB40E8E5137428 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 86D3F0629756097A5955B6A5 /* NetFlow */, + 19EB3BDA957FAB4A1D180062 /* NetFlowTests */, + ); + }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - D00000000000000000000008 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - A20000000000000000000001 /* Assets.xcassets in Resources */, - BC489C0029FE233D12E3211D /* en.lproj/Localizable.strings in Resources */, - 17ACAC1695DCD4C77F44DD8E /* vi.lproj/Localizable.strings in Resources */, - ); runOnlyForDeploymentPostprocessing = 0; }; + 5F776BFFE2CC54EA8B30E704 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1689C539C8CBEF3728665E2F /* Assets.xcassets in Resources */, + 87E8332964C3C8A52BD69A32 /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - D00000000000000000000006 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - A10000000000000000000001 /* SystemCapabilities.swift in Sources */, - A10000000000000000000002 /* SystemCapabilitiesService.swift in Sources */, - A10000000000000000000003 /* SystemCapabilitiesView.swift in Sources */, - A10000000000000000000004 /* AppChrome.swift in Sources */, - 40B986A4B04DA1316E6B0134 /* AppStore.swift in Sources */, - 521902CA3B6BBBBC62DB2FA7 /* NetFlowApp.swift in Sources */, - 8A359A8D84E62D35FDB66F1B /* AppSettings.swift in Sources */, - 542DF0191ACA2810C131B99D /* DailyUsageRecord.swift in Sources */, - D2270CD05B2FC1D0A67EB411 /* DataPlan.swift in Sources */, - 63D66C18AB73E0DFC9C660E4 /* NetworkModels.swift in Sources */, - BC9527A9ADE6C0FC9B6A5634 /* Formatters.swift in Sources */, - 5FAC6EEEB74132BBCD72AAAB /* NetworkInterfaceReader.swift in Sources */, - 837828955A4C7990199751CB /* PDFReportService.swift in Sources */, - A05DF6A196B78DA6CAAF43DD /* PersistenceService.swift in Sources */, - 1CA042ED5DD90E6E6B21E4AE /* UsageTracker.swift in Sources */, - 752CB43A93A37FC248AE24CE /* UsageCard.swift in Sources */, - 40D1A72FF577054CC672548B /* DataPlanView.swift in Sources */, - A884142D17D4815F9779EDCC /* HistoryView.swift in Sources */, - 7C633070DB01638B975AE4E9 /* OverviewView.swift in Sources */, - ECA2504668DE0828127450F0 /* RootTabView.swift in Sources */, - 4AB6E687D2B790F2BC3AD3A8 /* SettingsView.swift in Sources */, - ); runOnlyForDeploymentPostprocessing = 0; }; + 91507C4F635DC0682C55A788 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B537058BA665D9068531C9F9 /* AppStoreTests.swift in Sources */, + D11BDB9CC517858763953BEE /* DataPlanTests.swift in Sources */, + DB6DDE1D98A3C160AE48F694 /* PersistenceServiceTests.swift in Sources */, + A10B883F3A19B91ACE2867B8 /* UsageTrackerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DE5D6BA907B480E5049F052F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5C933FA47C0C6F23592A5315 /* AppChrome.swift in Sources */, + 69FB51483FDE63DD07E2F7E6 /* AppSettings.swift in Sources */, + 228EDE607E5DCD36B97B7E93 /* AppStore.swift in Sources */, + CF81A868D086A2FC4D7E6438 /* DailyUsageRecord.swift in Sources */, + D98774FAA9EDF290950EDB5B /* DataPlan.swift in Sources */, + B83C0AEBE464551AB8F6EB0C /* DataPlanView.swift in Sources */, + A2B6DE404ACF641034CB653E /* Formatters.swift in Sources */, + 6315AA4CE266139EB971B651 /* HistoryView.swift in Sources */, + 304FF9D84A862007C8B4BE8A /* NetFlowApp.swift in Sources */, + 51D6A1F5AFEEEADB713D3381 /* NetworkInterfaceReader.swift in Sources */, + 988D23A017B7832255EDE20D /* NetworkModels.swift in Sources */, + B6451560C472B6D274249D24 /* OverviewView.swift in Sources */, + 88BF570D4A7666A3BCD2E88D /* PDFReportService.swift in Sources */, + 80FB2ED1EA3FC610E3905FC4 /* PersistenceService.swift in Sources */, + 924429FEE7F54C5BE8277EA3 /* RootTabView.swift in Sources */, + 79A31E915726C531395B9598 /* SettingsView.swift in Sources */, + 9BB3906062A78AC726C06815 /* SystemCapabilities.swift in Sources */, + 26C29F1B8D824052CD733D2A /* SystemCapabilitiesService.swift in Sources */, + 79451DBE4DC1BEFC54A6E2DB /* SystemCapabilitiesView.swift in Sources */, + EAB7617EA73E7CFD22E533F3 /* UsageCard.swift in Sources */, + 35A16B36A8168AAD7A401C43 /* UsageTracker.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 4EFF36361E95C6722EEA69FD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 86D3F0629756097A5955B6A5 /* NetFlow */; + targetProxy = 9CAC58420B3BCB933D1ABD3A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + CDD6E81AB202F0850AED19C5 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 6CBC315C8A1D15CCEBDF544B /* en */, + D1F27CFF0A04A2D93DE154D1 /* vi */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin XCBuildConfiguration section */ - D0000000000000000000000B = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ENABLE_MODULES = YES; SDKROOT = iphoneos; IPHONEOS_DEPLOYMENT_TARGET = 16.0; }; name = Debug; }; - D0000000000000000000000C = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ENABLE_MODULES = YES; SDKROOT = iphoneos; IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; }; - D0000000000000000000000D = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = NetFlow/NetFlow.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = NetFlow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 4.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.duyhoang.netflow; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; - D0000000000000000000000E = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = NetFlow/NetFlow.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = NetFlow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 4.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.duyhoang.netflow; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; }; + 3B6AE2B171E75E6AEB11E271 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = NetFlow/NetFlow.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 29; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NetFlow/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 4.2.0; + PRODUCT_BUNDLE_IDENTIFIER = com.duyhoang.netflow; + PRODUCT_NAME = NetFlow; + SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7AA4872AE207F389200C182A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 7AA945FD8E03F0F1F001D6ED /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.duyhoang.netflow.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NetFlow.app/NetFlow"; + }; + name = Release; + }; + CAC663B0ACC6734819A69478 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = NetFlow/NetFlow.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 29; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NetFlow/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 4.2.0; + PRODUCT_BUNDLE_IDENTIFIER = com.duyhoang.netflow; + PRODUCT_NAME = NetFlow; + SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + CC795959036DDAA808727577 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.duyhoang.netflow.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NetFlow.app/NetFlow"; + }; + name = Debug; + }; + E422ED4552EE4FE75F457DF8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - D00000000000000000000009 = {isa = XCConfigurationList; buildConfigurations = (D0000000000000000000000B,D0000000000000000000000C); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - D0000000000000000000000A = {isa = XCConfigurationList; buildConfigurations = (D0000000000000000000000D,D0000000000000000000000E); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 395C976E31A88340EF5335AD /* Build configuration list for PBXNativeTarget "NetFlow" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3B6AE2B171E75E6AEB11E271 /* Debug */, + CAC663B0ACC6734819A69478 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 42784119405FA3D8EA9F81DB /* Build configuration list for PBXProject "NetFlow" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7AA4872AE207F389200C182A /* Debug */, + E422ED4552EE4FE75F457DF8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 697664B9C5721573A0BEA1CA /* Build configuration list for PBXNativeTarget "NetFlowTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CC795959036DDAA808727577 /* Debug */, + 7AA945FD8E03F0F1F001D6ED /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ }; - rootObject = D00000000000000000000002 /* Project object */; + rootObject = C52E1182A4D9012D6EB78D7E /* Project object */; } diff --git a/NetFlow.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/NetFlow.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/NetFlow.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/NetFlow/App/AppStore.swift b/NetFlow/App/AppStore.swift index f21116c..e79c303 100644 --- a/NetFlow/App/AppStore.swift +++ b/NetFlow/App/AppStore.swift @@ -176,8 +176,9 @@ final class AppStore: ObservableObject { let interval = plan.cycleInterval(containing: date) let measured = dailyRecords .filter { interval.contains($0.date) } - .reduce(UInt64(0)) { $0 + $1.cellularTotalBytes } - return measured &+ plan.manualUsedBytes + .reduce(UInt64(0)) { $0 &+ $1.cellularTotalBytes } + let manual = interval.start == plan.activeCycleStart ? plan.manualUsedBytes : 0 + return measured &+ manual } func isPlanExceeded(at date: Date = Date()) -> Bool { diff --git a/NetFlow/Models/DataPlan.swift b/NetFlow/Models/DataPlan.swift index 5e888bd..98fc65c 100644 --- a/NetFlow/Models/DataPlan.swift +++ b/NetFlow/Models/DataPlan.swift @@ -59,7 +59,11 @@ struct DataPlan: Codable, Hashable { var triggeredAlertIDs: Set = [] var isUnlimited: Bool { cycleType == .unlimited } - var effectiveCapacityBytes: UInt64 { isUnlimited ? UInt64.max : capacityBytes + carriedBytes } + var effectiveCapacityBytes: UInt64 { + guard !isUnlimited else { return UInt64.max } + let (capacity, overflow) = capacityBytes.addingReportingOverflow(carriedBytes) + return overflow ? UInt64.max : capacity + } func displayName(locale: Locale) -> String { (name == Self.defaultName || name == Self.legacyDefaultName) @@ -74,21 +78,35 @@ struct DataPlan: Codable, Hashable { case .daily: return DateInterval(start: startOfDay, end: cal.date(byAdding: .day, value: 1, to: startOfDay)!) case .monthly: - var comps = cal.dateComponents([.year, .month], from: date) - let maxDay = cal.range(of: .day, in: .month, for: date)?.count ?? 28 - comps.day = min(monthlyResetDay, maxDay) - var start = cal.date(from: comps) ?? startOfDay - if date < start { - start = cal.date(byAdding: .month, value: -1, to: start)! + let monthStart = cal.date(from: cal.dateComponents([.year, .month], from: date)) ?? startOfDay + let currentBoundary = monthlyBoundary(containingMonth: monthStart, calendar: cal) ?? monthStart + let start: Date + if date < currentBoundary { + let previousMonth = cal.date(byAdding: .month, value: -1, to: monthStart) ?? monthStart + start = monthlyBoundary(containingMonth: previousMonth, calendar: cal) ?? previousMonth + } else { + start = currentBoundary } - return DateInterval(start: start, end: cal.date(byAdding: .month, value: 1, to: start)!) + let startMonth = cal.date(from: cal.dateComponents([.year, .month], from: start)) ?? start + let nextMonth = cal.date(byAdding: .month, value: 1, to: startMonth) ?? startMonth + let end = monthlyBoundary(containingMonth: nextMonth, calendar: cal) + ?? cal.date(byAdding: .month, value: 1, to: startMonth)! + return DateInterval(start: start, end: end) case .yearly: - var comps = cal.dateComponents([.year], from: date) - comps.month = yearlyResetMonth - comps.day = yearlyResetDay - var start = cal.date(from: comps) ?? startOfDay - if date < start { start = cal.date(byAdding: .year, value: -1, to: start)! } - return DateInterval(start: start, end: cal.date(byAdding: .year, value: 1, to: start)!) + let yearStart = cal.date(from: cal.dateComponents([.year], from: date)) ?? startOfDay + let currentBoundary = yearlyBoundary(containingYear: yearStart, calendar: cal) ?? yearStart + let start: Date + if date < currentBoundary { + let previousYear = cal.date(byAdding: .year, value: -1, to: yearStart) ?? yearStart + start = yearlyBoundary(containingYear: previousYear, calendar: cal) ?? previousYear + } else { + start = currentBoundary + } + let startYear = cal.date(from: cal.dateComponents([.year], from: start)) ?? start + let nextYear = cal.date(byAdding: .year, value: 1, to: startYear) ?? startYear + let end = yearlyBoundary(containingYear: nextYear, calendar: cal) + ?? cal.date(byAdding: .year, value: 1, to: startYear)! + return DateInterval(start: start, end: end) case .custom: let days = max(customDays, 1) let elapsed = cal.dateComponents([.day], from: cal.startOfDay(for: cycleAnchor), to: startOfDay).day ?? 0 @@ -104,8 +122,13 @@ struct DataPlan: Codable, Hashable { func remainingBytes(records: [DailyUsageRecord], at date: Date = Date()) -> UInt64 { guard !isUnlimited else { return UInt64.max } let interval = cycleInterval(containing: date) - let used = records.filter { interval.contains($0.date) }.reduce(0) { $0 + $1.cellularTotalBytes } + manualUsedBytes - return effectiveCapacityBytes > used ? effectiveCapacityBytes - used : 0 + let measured = records + .filter { interval.contains($0.date) } + .reduce(UInt64(0)) { $0 &+ $1.cellularTotalBytes } + let manual = interval.start == activeCycleStart ? manualUsedBytes : 0 + let used = measured &+ manual + let capacity = interval.start == activeCycleStart ? effectiveCapacityBytes : capacityBytes + return capacity > used ? capacity - used : 0 } func forecast(records: [DailyUsageRecord], at date: Date = Date()) -> UsageForecast? { @@ -116,7 +139,8 @@ struct DataPlan: Codable, Hashable { let measured = records .filter { interval.contains($0.date) && $0.date <= boundedNow } .reduce(UInt64(0)) { $0 &+ $1.cellularTotalBytes } - let used = measured &+ manualUsedBytes + let manual = interval.start == activeCycleStart ? manualUsedBytes : 0 + let used = measured &+ manual // Use at least one day as the observation window so a few minutes of // early-cycle traffic do not produce an unrealistically large forecast. @@ -137,8 +161,29 @@ struct DataPlan: Codable, Hashable { usedBytes: used, averageDailyBytes: average, projectedBytes: projected, - capacityBytes: effectiveCapacityBytes, + capacityBytes: interval.start == activeCycleStart ? effectiveCapacityBytes : capacityBytes, cycleEnd: interval.end ) } + + private func monthlyBoundary(containingMonth month: Date, calendar cal: Calendar) -> Date? { + let monthStart = cal.date(from: cal.dateComponents([.year, .month], from: month)) ?? month + let monthComponents = cal.dateComponents([.year, .month], from: monthStart) + let monthEnd = cal.date(byAdding: DateComponents(month: 1, day: -1), to: monthStart) + let lastDay = monthEnd.map { cal.component(.day, from: $0) } ?? 28 + var components = monthComponents + components.day = min(max(monthlyResetDay, 1), lastDay) + return cal.date(from: components) + } + + private func yearlyBoundary(containingYear year: Date, calendar cal: Calendar) -> Date? { + let yearComponents = cal.dateComponents([.year], from: year) + var components = yearComponents + components.month = min(max(yearlyResetMonth, 1), 12) + let monthStart = cal.date(from: components) + let monthEnd = monthStart.flatMap { cal.date(byAdding: DateComponents(month: 1, day: -1), to: $0) } + let lastDay = monthEnd.map { cal.component(.day, from: $0) } ?? 28 + components.day = min(max(yearlyResetDay, 1), lastDay) + return cal.date(from: components) + } } diff --git a/NetFlow/Services/UsageTracker.swift b/NetFlow/Services/UsageTracker.swift index 7d71083..ff38a57 100644 --- a/NetFlow/Services/UsageTracker.swift +++ b/NetFlow/Services/UsageTracker.swift @@ -1,9 +1,19 @@ import Foundation +protocol NetworkSnapshotReading { + func read() -> NetworkSnapshot +} + +extension NetworkInterfaceReader: NetworkSnapshotReading {} + final class UsageTracker { - private let reader = NetworkInterfaceReader() + private let reader: any NetworkSnapshotReading private var previous: NetworkSnapshot? + init(reader: any NetworkSnapshotReading = NetworkInterfaceReader()) { + self.reader = reader + } + struct SampleResult { var snapshot: NetworkSnapshot var delta: NetworkDelta diff --git a/NetFlow/Views/DataPlan/DataPlanView.swift b/NetFlow/Views/DataPlan/DataPlanView.swift index 82e916e..410bd04 100644 --- a/NetFlow/Views/DataPlan/DataPlanView.swift +++ b/NetFlow/Views/DataPlan/DataPlanView.swift @@ -54,7 +54,7 @@ struct DataPlanView: View { Toggle("rollover", isOn: $store.plan.rolloverEnabled) if store.plan.cycleType == .monthly { - Stepper(value: $store.plan.monthlyResetDay, in: 1...28) { + Stepper(value: $store.plan.monthlyResetDay, in: 1...31) { Text("\(AppLocalization.string("reset_day", locale: appLocale)) \(store.plan.monthlyResetDay)") } } @@ -62,7 +62,7 @@ struct DataPlanView: View { Stepper(value: $store.plan.yearlyResetMonth, in: 1...12) { Text("\(AppLocalization.string("reset_month", locale: appLocale)) \(store.plan.yearlyResetMonth)") } - Stepper(value: $store.plan.yearlyResetDay, in: 1...28) { + Stepper(value: $store.plan.yearlyResetDay, in: 1...31) { Text("\(AppLocalization.string("reset_day", locale: appLocale)) \(store.plan.yearlyResetDay)") } } diff --git a/NetFlowTests/AppStoreTests.swift b/NetFlowTests/AppStoreTests.swift new file mode 100644 index 0000000..039926b --- /dev/null +++ b/NetFlowTests/AppStoreTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import NetFlow + +@MainActor +final class AppStoreTests: XCTestCase { + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return calendar + } + + private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int = 0) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour))! + } + + func testMergeSplitsTrafficAcrossLocalMidnightAndPreservesTotal() { + let store = AppStore() + store.dailyRecords = [] + + store.merge( + delta: NetworkDelta( + wifiReceived: 100, + wifiSent: 20, + cellularReceived: 300, + cellularSent: 80, + isValid: true + ), + from: date(2026, 1, 10, 23), + to: date(2026, 1, 11, 1) + ) + + XCTAssertEqual(store.dailyRecords.count, 2) + XCTAssertEqual(store.dailyRecords.reduce(UInt64(0)) { $0 &+ $1.totalBytes }, 500) + XCTAssertEqual(store.dailyRecords.first(where: { $0.date == date(2026, 1, 10) })?.totalBytes, 250) + XCTAssertEqual(store.dailyRecords.first(where: { $0.date == date(2026, 1, 11) })?.totalBytes, 250) + } + + func testPlanUsageIgnoresManualUsageForAClosedCycle() { + let store = AppStore() + store.plan.cycleType = .daily + store.plan.capacityBytes = 1_000 + store.plan.activeCycleStart = date(2026, 1, 11) + store.plan.activeCycleEnd = date(2026, 1, 12) + store.plan.manualUsedBytes = 300 + + XCTAssertEqual(store.planUsage(at: date(2026, 1, 10, 12)), 0) + XCTAssertEqual(store.planUsage(at: date(2026, 1, 11, 12)), 300) + } +} diff --git a/NetFlowTests/DataPlanTests.swift b/NetFlowTests/DataPlanTests.swift new file mode 100644 index 0000000..78ab2c1 --- /dev/null +++ b/NetFlowTests/DataPlanTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import NetFlow + +final class DataPlanTests: XCTestCase { + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return calendar + } + + private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int = 0) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour))! + } + + func testMonthlyResetDay31ClampsToShortMonthWithoutGap() { + var plan = DataPlan() + plan.cycleType = .monthly + plan.monthlyResetDay = 31 + + let april = plan.cycleInterval(containing: date(2026, 4, 30, 12)) + XCTAssertEqual(april.start, date(2026, 4, 30)) + XCTAssertEqual(april.end, date(2026, 5, 31)) + + let mayBoundary = plan.cycleInterval(containing: date(2026, 5, 31)) + XCTAssertEqual(mayBoundary.start, date(2026, 5, 31)) + XCTAssertEqual(mayBoundary.end, date(2026, 6, 30)) + } + + func testYearlyResetDay29ClampsNonLeapYear() { + var plan = DataPlan() + plan.cycleType = .yearly + plan.yearlyResetMonth = 2 + plan.yearlyResetDay = 29 + + let interval = plan.cycleInterval(containing: date(2025, 3, 1)) + XCTAssertEqual(interval.start, date(2025, 2, 28)) + XCTAssertEqual(interval.end, date(2026, 2, 28)) + + let leapInterval = plan.cycleInterval(containing: date(2024, 3, 1)) + XCTAssertEqual(leapInterval.start, date(2024, 2, 29)) + XCTAssertEqual(leapInterval.end, date(2025, 2, 28)) + } + + func testForecastUsesCurrentCycleAndIncludesManualUsage() throws { + var plan = DataPlan() + plan.cycleType = .daily + plan.capacityBytes = 1_000 + plan.activeCycleStart = date(2026, 1, 10) + plan.activeCycleEnd = date(2026, 1, 11) + plan.manualUsedBytes = 100 + + let record = DailyUsageRecord( + date: date(2026, 1, 10), + delta: NetworkDelta( + wifiReceived: 0, + wifiSent: 0, + cellularReceived: 200, + cellularSent: 0, + isValid: true + ), + firstUpdated: date(2026, 1, 10, 8), + lastUpdated: date(2026, 1, 10, 9) + ) + + let forecast = try XCTUnwrap(plan.forecast(records: [record], at: date(2026, 1, 10, 12))) + XCTAssertEqual(forecast.usedBytes, 300) + XCTAssertEqual(forecast.capacityBytes, 1_000) + XCTAssertEqual(forecast.projectedBytes, 300) + XCTAssertFalse(forecast.isProjectedToExceed) + } + + func testClosedCycleRemainingDoesNotUseActiveCycleManualUsage() { + var plan = DataPlan() + plan.cycleType = .daily + plan.capacityBytes = 1_000 + plan.activeCycleStart = date(2026, 1, 11) + plan.activeCycleEnd = date(2026, 1, 12) + plan.manualUsedBytes = 700 + + let previousRecord = DailyUsageRecord( + date: date(2026, 1, 10), + delta: NetworkDelta( + wifiReceived: 0, + wifiSent: 0, + cellularReceived: 200, + cellularSent: 0, + isValid: true + ), + firstUpdated: date(2026, 1, 10, 8), + lastUpdated: date(2026, 1, 10, 9) + ) + + XCTAssertEqual(plan.remainingBytes(records: [previousRecord], at: date(2026, 1, 10, 23)), 800) + } +} diff --git a/NetFlowTests/PersistenceServiceTests.swift b/NetFlowTests/PersistenceServiceTests.swift new file mode 100644 index 0000000..c5a1e2e --- /dev/null +++ b/NetFlowTests/PersistenceServiceTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import NetFlow + +final class PersistenceServiceTests: XCTestCase { + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return calendar + } + + private func date(_ year: Int, _ month: Int, _ day: Int) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day))! + } + + private func sampleRecord() -> DailyUsageRecord { + DailyUsageRecord( + date: date(2026, 1, 10), + delta: NetworkDelta( + wifiReceived: 10, + wifiSent: 20, + cellularReceived: 30, + cellularSent: 40, + isValid: true + ), + firstUpdated: date(2026, 1, 10), + lastUpdated: date(2026, 1, 10) + ) + } + + func testBackupRoundTripPreservesPayload() throws { + var plan = DataPlan() + plan.capacityBytes = 5_000 + plan.cycleType = .custom + plan.customDays = 14 + let settings = AppSettings(appLanguage: .english, theme: .dark, refreshSeconds: 5) + let record = sampleRecord() + let alert = UsageAlertEvent( + date: date(2026, 1, 10), + threshold: AlertThreshold(kind: .percentUsed, value: 80), + remainingBytes: 1_000 + ) + + let service = PersistenceService() + let url = try service.makeBackup( + settings: settings, + plan: plan, + records: [record], + alerts: [alert] + ) + let restored = try service.loadBackup(from: url) + + XCTAssertEqual(restored.settings, settings) + XCTAssertEqual(restored.plan, plan) + XCTAssertEqual(restored.records, [record]) + XCTAssertEqual(restored.alerts, [alert]) + } + + func testCSVContainsStableByteColumnsAndSortedRecords() throws { + let first = sampleRecord() + var second = sampleRecord() + second.date = date(2026, 1, 11) + let interval = DateInterval(start: date(2026, 1, 10), end: date(2026, 1, 12)) + + let url = try PersistenceService().makeCSV(records: [second, first], interval: interval) + let csv = try String(contentsOf: url, encoding: .utf8) + let lines = csv.split(separator: "\n").map(String.init) + + XCTAssertEqual(lines.count, 3) + XCTAssertEqual( + lines[0], + "date,wifi_received_bytes,wifi_sent_bytes,wifi_total_bytes,cellular_received_bytes,cellular_sent_bytes,cellular_total_bytes,total_bytes,estimated" + ) + XCTAssertTrue(lines[1].hasPrefix("2026-01-10,10,20,30,30,40,70,100,false")) + XCTAssertTrue(lines[2].hasPrefix("2026-01-11,10,20,30,30,40,70,100,false")) + } +} diff --git a/NetFlowTests/UsageTrackerTests.swift b/NetFlowTests/UsageTrackerTests.swift new file mode 100644 index 0000000..894d7ed --- /dev/null +++ b/NetFlowTests/UsageTrackerTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import NetFlow + +final class UsageTrackerTests: XCTestCase { + private final class StubReader: NetworkSnapshotReading { + var snapshots: [NetworkSnapshot] + + init(_ snapshots: [NetworkSnapshot]) { + self.snapshots = snapshots + } + + func read() -> NetworkSnapshot { + snapshots.removeFirst() + } + } + + private func snapshot(_ timestamp: TimeInterval, wifi: UInt64, cellular: UInt64) -> NetworkSnapshot { + NetworkSnapshot( + wifi: NetworkCounter(received: wifi, sent: 10), + cellular: NetworkCounter(received: cellular, sent: 20), + timestamp: Date(timeIntervalSince1970: timestamp) + ) + } + + func testFirstSampleUsesZeroDeltaAndSecondSampleCalculatesRate() { + let reader = StubReader([ + snapshot(100, wifi: 100, cellular: 300), + snapshot(110, wifi: 160, cellular: 500) + ]) + let tracker = UsageTracker(reader: reader) + + let first = tracker.sample(previous: .zero) + XCTAssertFalse(first.delta.isValid) + XCTAssertEqual(first.delta.totalBytes, 0) + + let second = tracker.sample(previous: first.snapshot) + XCTAssertTrue(second.delta.isValid) + XCTAssertEqual(second.delta.wifiReceived, 60) + XCTAssertEqual(second.delta.cellularReceived, 200) + XCTAssertEqual(second.rate.cellularDown, 20, accuracy: 0.001) + } + + func testCounterResetInvalidatesSample() { + let reader = StubReader([ + snapshot(100, wifi: 100, cellular: 300), + snapshot(110, wifi: 90, cellular: 350) + ]) + let tracker = UsageTracker(reader: reader) + + _ = tracker.sample(previous: .zero) + let result = tracker.sample(previous: snapshot(100, wifi: 100, cellular: 300)) + + XCTAssertFalse(result.delta.isValid) + XCTAssertEqual(result.rate, .zero) + } +} diff --git a/NetFlow_Universal_v4_1_10_build26.ipa b/NetFlow_Universal_v4_1_10_build26.ipa deleted file mode 100644 index 4a2266e..0000000 Binary files a/NetFlow_Universal_v4_1_10_build26.ipa and /dev/null differ diff --git a/NetFlow_Universal_v4_1_11_build27.ipa b/NetFlow_Universal_v4_1_11_build27.ipa deleted file mode 100644 index ded8e4e..0000000 Binary files a/NetFlow_Universal_v4_1_11_build27.ipa and /dev/null differ diff --git a/NetFlow_Universal_v4_1_12_build28.ipa b/NetFlow_Universal_v4_1_12_build28.ipa deleted file mode 100644 index a7390b7..0000000 Binary files a/NetFlow_Universal_v4_1_12_build28.ipa and /dev/null differ diff --git a/NetFlow_Universal_v4_1_8_build24.ipa b/NetFlow_Universal_v4_1_8_build24.ipa deleted file mode 100644 index 3b10a06..0000000 Binary files a/NetFlow_Universal_v4_1_8_build24.ipa and /dev/null differ diff --git a/NetFlow_Universal_v4_1_9_build25.ipa b/NetFlow_Universal_v4_1_9_build25.ipa deleted file mode 100644 index 203f226..0000000 Binary files a/NetFlow_Universal_v4_1_9_build25.ipa and /dev/null differ diff --git a/README.md b/README.md index dfc9684..cd5fd8c 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,23 @@ bash Tools/check_project.sh ## Releases -GitHub Releases provide source snapshots and an automatically built **unsigned IPA**. Unsigned IPAs require an appropriate sideloading/signing method before installation on a normal device. +GitHub [Releases](https://github.com/hnduy910/NetFlow/releases) provide source snapshots and an automatically built **unsigned IPA** after the main-branch CI workflow passes. Unsigned IPAs require an appropriate sideloading/signing method before installation on a normal device; they are not App Store builds and are not guaranteed to install without signing. + +## Test locally + +The repository includes XCTest coverage for plan-cycle boundaries, forecast calculations, counter-reset handling, midnight aggregation, persistence, backup/restore, and CSV output. + +```bash +bash Tools/check_project.sh +xcodebuild \ + -project NetFlow.xcodeproj \ + -scheme NetFlow \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \ + CODE_SIGNING_ALLOWED=NO \ + test +``` + +Some networking behavior (cellular counters, VPN interfaces, carrier information, and location services) still requires validation on a physical device. ## Repository layout @@ -100,6 +116,16 @@ Bug reports, documentation improvements, focused feature proposals, and pull req Please read [CONTRIBUTING.md](CONTRIBUTING.md), [ROADMAP.md](ROADMAP.md), [MAINTAINERS.md](MAINTAINERS.md), [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md), and [SECURITY.md](SECURITY.md) before contributing. + + + + + + +
🐛 Report a bug💡 Request a feature🤝 Contribute
+ +If NetFlow is useful to you, a GitHub star is welcome. Please report bugs and ideas as issues instead of using stars as a support channel. + ## License NetFlow is available under the [MIT License](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 7fda3d6..2df6599 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,6 +12,8 @@ Please do not disclose a suspected vulnerability in a public issue. Use GitHub's private vulnerability reporting feature from the repository's **Security** tab when it is available. If private reporting is not enabled, contact the maintainer through the GitHub profile and request a private channel before sharing sensitive details. +You can also start a private report directly from [Security advisories](https://github.com/hnduy910/NetFlow/security/advisories/new). Do not paste credentials, private network identifiers, or an exploit proof of concept into a public issue. + Include: - a clear description of the issue and its impact; @@ -28,4 +30,3 @@ Reports are reviewed as time permits. The maintainer may ask for additional deta ## Scope Reports about the NetFlow source code, build scripts, documentation, and repository configuration are in scope. Issues caused solely by Apple platform behavior or unrelated third-party services may need to be reported to their respective maintainers as well. - diff --git a/Tools/check_project.sh b/Tools/check_project.sh index 2c40afb..0ff7f3f 100644 --- a/Tools/check_project.sh +++ b/Tools/check_project.sh @@ -2,13 +2,13 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" -echo "[1/7] Swift parse" -swiftc -parse $(find NetFlow -name '*.swift' -print | sort) -echo "[2/7] Property lists" +echo "[1/8] Swift parse" +swiftc -parse $(find NetFlow NetFlowTests -name '*.swift' -print 2>/dev/null | sort) +echo "[2/8] Property lists" plutil -lint NetFlow/Info.plist NetFlow/NetFlow.entitlements -echo "[3/7] Localizations" +echo "[3/8] Localizations" plutil -lint NetFlow/Resources/en.lproj/Localizable.strings NetFlow/Resources/vi.lproj/Localizable.strings -echo "[4/7] App icon" +echo "[4/8] App icon" test -s NetFlow/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png if command -v sips >/dev/null 2>&1; then ICON_INFO="$(sips -g pixelWidth -g pixelHeight NetFlow/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png)" @@ -24,18 +24,23 @@ assert im.mode in ('RGB','RGBA'), im.mode print('App icon:', im.size, im.mode) PY fi -echo "[5/7] Source membership" +echo "[5/8] Source membership" python3 - <<'PY' from pathlib import Path pbx=Path('NetFlow.xcodeproj/project.pbxproj').read_text() -missing=[str(p) for p in Path('NetFlow').rglob('*.swift') if p.name not in pbx] +swift_files = list(Path('NetFlow').rglob('*.swift')) + list(Path('NetFlowTests').rglob('*.swift')) +missing=[str(p) for p in swift_files if p.name not in pbx] assert not missing, 'Swift files missing from project: '+str(missing) print('All Swift files referenced by project') PY -echo "[6/7] iOS 16 API guard" +echo "[6/8] iOS 16 API guard" ! grep -R '\.topBarTrailing' NetFlow --include='*.swift' -echo "[7/7] Required bundle keys" +echo "[7/8] Required bundle keys" for key in CFBundleExecutable CFBundleIdentifier CFBundlePackageType CFBundleShortVersionString CFBundleVersion; do /usr/libexec/PlistBuddy -c "Print :$key" NetFlow/Info.plist >/dev/null 2>&1 || plutil -extract "$key" raw NetFlow/Info.plist >/dev/null done +echo "[8/8] XCTest target" +test -d NetFlowTests +grep -q 'NetFlowTests' NetFlow.xcodeproj/project.pbxproj +test "$(find NetFlowTests -name '*Tests.swift' -print | wc -l | tr -d ' ')" -ge 1 echo "Static project checks passed. Run xcodebuild on macOS for the final SDK compile/signing check." diff --git a/project.yml b/project.yml index 53f81e9..13d057c 100644 --- a/project.yml +++ b/project.yml @@ -1,5 +1,7 @@ name: NetFlow options: + developmentLanguage: en + projectFormat: xcode15_0 deploymentTarget: iOS: "16.0" xcodeVersion: "15.0" @@ -12,9 +14,10 @@ targets: - path: NetFlow excludes: - Preview - - Resources resources: - - path: NetFlow/Resources + - path: NetFlow/Resources/Assets.xcassets + - path: NetFlow/Resources/en.lproj + - path: NetFlow/Resources/vi.lproj settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.duyhoang.netflow @@ -30,3 +33,56 @@ targets: INFOPLIST_FILE: NetFlow/Info.plist info: path: NetFlow/Info.plist + properties: + CFBundleDevelopmentRegion: en + CFBundleDisplayName: NetFlow + CFBundleShortVersionString: 4.2.0 + CFBundleVersion: "29" + CFBundleIconName: AppIcon + CFBundleIcons: + CFBundlePrimaryIcon: + CFBundleIconFiles: + - AppIcon60x60 + - AppIcon76x76 + CFBundleIconName: AppIcon + CFBundleIcons~ipad: + CFBundlePrimaryIcon: + CFBundleIconFiles: + - AppIcon76x76 + - AppIcon83.5x83.5 + CFBundleIconName: AppIcon + CFBundleLocalizations: + - en + - vi + ITSAppUsesNonExemptEncryption: false + LSRequiresIPhoneOS: true + NSLocalNetworkUsageDescription: NetFlow reads local network details to display connection information. + NSLocationWhenInUseUsageDescription: NetFlow uses your current location to display local weather for your area. + NetFlowBuildDate: 22/09/2026 + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: false + UIApplicationSupportsIndirectInputEvents: true + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + NetFlowTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "16.0" + sources: + - path: NetFlowTests + dependencies: + - target: NetFlow + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.duyhoang.netflow.tests + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/NetFlow.app/NetFlow" + BUNDLE_LOADER: "$(TEST_HOST)"